kitcn 0.16.0 → 0.17.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 (38) hide show
  1. package/dist/aggregate/index.d.ts +1 -1
  2. package/dist/auth/client/index.js +1 -1
  3. package/dist/auth/index.js +19 -21
  4. package/dist/auth/nextjs/index.d.ts +1 -1
  5. package/dist/auth/nextjs/index.js +4 -4
  6. package/dist/{auth-store-ssZDPa37.js → auth-store-BnGZxmnY.js} +4 -1
  7. package/dist/{backend-core-DqPydYyx.mjs → backend-core-BsKP1LVg.mjs} +204 -164
  8. package/dist/{builder-DBgto1yn.js → builder-f4F_NRvK.js} +245 -153
  9. package/dist/{caller-factory-NEfgD5E0.js → caller-factory-DHywSoGZ.js} +7 -5
  10. package/dist/cli.mjs +14 -7
  11. package/dist/crpc/index.js +1 -127
  12. package/dist/{middleware-Bg-PdtrI.js → middleware-Cgrv2jIu.js} +1 -1
  13. package/dist/orm/index.d.ts +1 -1
  14. package/dist/orm/index.js +486 -121
  15. package/dist/plugins/index.js +1 -1
  16. package/dist/{procedure-caller-9m6NBxQu.js → procedure-caller-Rj6z3ai7.js} +1 -1
  17. package/dist/{procedure-name-Cy1AxayA.d.ts → procedure-name-Bo5KMcqc.d.ts} +15 -3
  18. package/dist/{query-context-ydn9kb6P.js → query-context-C90vNlc9.js} +131 -30
  19. package/dist/query-options-C_eBSIXG.js +247 -0
  20. package/dist/ratelimit/index.d.ts +26 -6
  21. package/dist/ratelimit/index.js +427 -100
  22. package/dist/ratelimit/react/index.d.ts +14 -0
  23. package/dist/ratelimit/react/index.js +149 -16
  24. package/dist/react/index.d.ts +3 -1
  25. package/dist/react/index.js +48 -15
  26. package/dist/rsc/index.js +22 -33
  27. package/dist/server/index.d.ts +1 -1
  28. package/dist/server/index.js +3 -3
  29. package/dist/solid/index.js +19 -5
  30. package/dist/watcher.mjs +2 -2
  31. package/dist/{where-clause-compiler-WF9UcrAB.d.ts → where-clause-compiler-BRhLW1dp.d.ts} +51 -0
  32. package/package.json +1 -1
  33. package/skills/kitcn/SKILL.md +1 -0
  34. package/skills/kitcn/references/features/create-plugins.md +1 -1
  35. package/skills/kitcn/references/features/orm.md +11 -1
  36. package/skills/kitcn/references/features/ratelimit.md +105 -0
  37. package/skills/kitcn/references/setup/server.md +1 -1
  38. package/dist/query-options-C96zLANM.js +0 -121
@@ -1,6 +1,6 @@
1
1
  import { u as requireMutationCtx } from "../api-entry-N3nBOlI2.js";
2
- import { _ as CRPCError } from "../builder-DBgto1yn.js";
3
- import { t as definePlugin } from "../middleware-Bg-PdtrI.js";
2
+ import { _ as CRPCError } from "../builder-f4F_NRvK.js";
3
+ import { t as definePlugin } from "../middleware-Cgrv2jIu.js";
4
4
  import { v } from "convex/values";
5
5
  import { mutationGeneric, queryGeneric } from "convex/server";
6
6
 
@@ -30,58 +30,121 @@ function toMs(duration) {
30
30
  const DEFAULT_SHARDS = 1;
31
31
  function fixedWindow(limit, window, options) {
32
32
  validatePositive(limit, "limit");
33
- const shards = normalizeShards(options?.shards);
34
33
  const capacity = options?.capacity ?? limit;
35
34
  validatePositive(capacity, "capacity");
36
- return {
35
+ validateMaxReserved(options?.maxReserved);
36
+ return assertShardBudget({
37
37
  kind: "fixedWindow",
38
38
  limit,
39
39
  window: toMs(window),
40
40
  capacity,
41
41
  maxReserved: options?.maxReserved,
42
42
  start: options?.start,
43
- shards
44
- };
43
+ shards: normalizeShards(options?.shards)
44
+ });
45
45
  }
46
46
  function slidingWindow(limit, window, options) {
47
47
  validatePositive(limit, "limit");
48
- return {
48
+ validateMaxReserved(options?.maxReserved);
49
+ return assertShardBudget({
49
50
  kind: "slidingWindow",
50
51
  limit,
51
52
  window: toMs(window),
52
53
  maxReserved: options?.maxReserved,
53
54
  shards: normalizeShards(options?.shards)
54
- };
55
+ });
55
56
  }
56
57
  function tokenBucket(refillRate, interval, maxTokens, options) {
57
58
  validatePositive(refillRate, "refillRate");
58
59
  validatePositive(maxTokens, "maxTokens");
59
- return {
60
+ validateMaxReserved(options?.maxReserved);
61
+ return assertShardBudget({
60
62
  kind: "tokenBucket",
61
63
  refillRate,
62
64
  interval: toMs(interval),
63
65
  maxTokens,
64
66
  maxReserved: options?.maxReserved,
65
67
  shards: normalizeShards(options?.shards)
66
- };
68
+ });
67
69
  }
68
70
  function applyDynamicLimit(algorithm, dynamicLimit) {
69
- if (!dynamicLimit || dynamicLimit <= 0) return algorithm;
70
- if (algorithm.kind === "tokenBucket") return {
71
+ if (dynamicLimit === null) return algorithm;
72
+ validatePositive(dynamicLimit, "dynamicLimit");
73
+ if (algorithm.kind === "tokenBucket") return assertShardBudget({
71
74
  ...algorithm,
72
75
  refillRate: dynamicLimit,
73
76
  maxTokens: algorithm.maxTokens === algorithm.refillRate ? dynamicLimit : algorithm.maxTokens
74
- };
75
- if (algorithm.kind === "fixedWindow") return {
77
+ });
78
+ if (algorithm.kind === "fixedWindow") return assertShardBudget({
76
79
  ...algorithm,
77
80
  limit: dynamicLimit,
78
81
  capacity: algorithm.capacity === algorithm.limit ? dynamicLimit : algorithm.capacity
82
+ });
83
+ return assertShardBudget({
84
+ ...algorithm,
85
+ limit: dynamicLimit
86
+ });
87
+ }
88
+ /**
89
+ * Narrow the configured budget down to the slice one shard owns.
90
+ *
91
+ * Every shard stores its own row and spends only what it owns, so without this
92
+ * the effective limit would be multiplied by the shard count. Shares are dealt
93
+ * so they add back up to the configured budget exactly — see {@link shardShare}.
94
+ */
95
+ function shardAlgorithm(algorithm, shard) {
96
+ const { shards } = algorithm;
97
+ if (shards <= 1) return algorithm;
98
+ const share = (value) => shardShare(value, shards, shard);
99
+ const maxReserved = algorithm.maxReserved === void 0 ? void 0 : share(algorithm.maxReserved);
100
+ if (algorithm.kind === "tokenBucket") {
101
+ const maxTokens = share(algorithm.maxTokens);
102
+ return {
103
+ ...algorithm,
104
+ refillRate: algorithm.refillRate * (maxTokens / algorithm.maxTokens),
105
+ maxTokens,
106
+ maxReserved,
107
+ shards: 1
108
+ };
109
+ }
110
+ if (algorithm.kind === "fixedWindow") return {
111
+ ...algorithm,
112
+ limit: share(algorithm.limit),
113
+ capacity: share(algorithm.capacity),
114
+ maxReserved,
115
+ shards: 1
79
116
  };
80
117
  return {
81
118
  ...algorithm,
82
- limit: dynamicLimit
119
+ limit: share(algorithm.limit),
120
+ maxReserved,
121
+ shards: 1
83
122
  };
84
123
  }
124
+ /** Tokens a shard-level config may spend, ignoring the per-window refill. */
125
+ function algorithmBudget(algorithm) {
126
+ return algorithm.kind === "tokenBucket" ? algorithm.maxTokens : algorithm.limit;
127
+ }
128
+ /** Tokens the algorithm can hold, including fixed-window burst capacity. */
129
+ function algorithmCapacity(algorithm) {
130
+ if (algorithm.kind === "fixedWindow") return algorithm.capacity;
131
+ return algorithmBudget(algorithm);
132
+ }
133
+ /**
134
+ * Deal a whole-token budget across `shards` so the shares sum back to `total`.
135
+ *
136
+ * A plain `total / shards` strands the fractional part of every shard: a shard
137
+ * holding `2.5` tokens only ever grants two whole requests and refills to `2.5`
138
+ * again, so `limit: 5` over two shards would enforce `4` forever. Whole budgets
139
+ * are dealt as `floor` plus one extra token to the first `total % shards` shards
140
+ * instead. Fractional budgets have no whole-token floor to hit, so they keep the
141
+ * even split.
142
+ */
143
+ function shardShare(total, shards, shard) {
144
+ const whole = Math.floor(total);
145
+ const fractional = total - whole;
146
+ return Math.floor(whole / shards) + (shard < whole % shards ? 1 : 0) + (shard === 0 ? fractional : 0);
147
+ }
85
148
  function normalizeShards(shards) {
86
149
  if (shards === void 0) return DEFAULT_SHARDS;
87
150
  const rounded = Math.round(shards);
@@ -91,14 +154,98 @@ function normalizeShards(shards) {
91
154
  function validatePositive(value, field) {
92
155
  if (!Number.isFinite(value) || value <= 0) throw new Error(`${field} must be a positive number`);
93
156
  }
157
+ function validateMaxReserved(value) {
158
+ if (value !== void 0 && (!Number.isFinite(value) || value < 0)) throw new Error("maxReserved must be a non-negative finite number");
159
+ }
160
+ /**
161
+ * Reject budgets that leave a shard with nothing to spend.
162
+ *
163
+ * Shares are dealt, so the smallest shard holds `floor(budget / shards)`. Once
164
+ * that reaches zero the shard is dead weight: it absorbs writes it can never
165
+ * grant, and the caller sees unexplained denials. Fail where the budget is
166
+ * configured instead — including the runtime override, which rewrites the
167
+ * budget after the builders ran.
168
+ */
169
+ function assertShardBudget(algorithm) {
170
+ const { shards } = algorithm;
171
+ if (algorithm.kind === "tokenBucket") {
172
+ validateShardBudget(algorithm.maxTokens, "maxTokens", shards);
173
+ return algorithm;
174
+ }
175
+ validateShardBudget(algorithm.limit, "limit", shards);
176
+ if (algorithm.kind === "fixedWindow") validateShardBudget(algorithm.capacity, "capacity", shards);
177
+ return algorithm;
178
+ }
179
+ function validateShardBudget(value, field, shards) {
180
+ if (value / shards < 1) throw new Error(`${field} (${value}) must be at least shards (${shards}). Sharding deals ${field} across the shards, and a share below one token cannot serve a request. Lower shards or raise ${field}.`);
181
+ }
94
182
 
95
183
  //#endregion
96
184
  //#region src/ratelimit/core/calculate-rate-limit.ts
97
185
  function calculateRatelimit(state, algorithm, now, count) {
186
+ const shardStates = state?.shards;
187
+ if (algorithm.shards > 1 && count > maximumShardCapacity(algorithm)) return {
188
+ ...calculateSingleRatelimit(state, algorithm, now, count),
189
+ retryAfter: Number.POSITIVE_INFINITY,
190
+ reset: Number.POSITIVE_INFINITY
191
+ };
192
+ if (algorithm.shards > 1 && shardStates?.length === algorithm.shards) return calculateShardedRatelimit(shardStates, algorithm, now, count);
193
+ return calculateSingleRatelimit(state, algorithm, now, count);
194
+ }
195
+ function calculateSingleRatelimit(state, algorithm, now, count) {
98
196
  if (algorithm.kind === "fixedWindow") return calculateFixedWindow(state, algorithm, now, count);
99
197
  if (algorithm.kind === "tokenBucket") return calculateTokenBucket(state, algorithm, now, count);
100
198
  return calculateSlidingWindow(state, algorithm, now, count);
101
199
  }
200
+ function maximumShardCapacity(algorithm) {
201
+ let maximum = 0;
202
+ for (let shard = 0; shard < algorithm.shards; shard += 1) maximum = Math.max(maximum, algorithmCapacity(shardAlgorithm(algorithm, shard)));
203
+ return maximum;
204
+ }
205
+ function calculateShardedRatelimit(shardStates, algorithm, now, count) {
206
+ const candidates = shardStates.map(({ shard, state: shardState }) => {
207
+ const perShard = shardAlgorithm(algorithm, shard);
208
+ return {
209
+ baseline: calculateRatelimit(shardState, perShard, now, 0),
210
+ requested: calculateRatelimit(shardState, perShard, now, count),
211
+ shard
212
+ };
213
+ });
214
+ const selected = [...count === 0 ? candidates : candidates.filter((candidate) => candidate.requested.retryAfter === void 0)].sort((a, b) => b.requested.remainingRaw - a.requested.remainingRaw)[0];
215
+ const projected = candidates.map((candidate) => ({
216
+ evaluated: count !== 0 && candidate.shard === selected?.shard ? candidate.requested : candidate.baseline,
217
+ shard: candidate.shard
218
+ }));
219
+ const retryValues = candidates.flatMap((candidate) => candidate.requested.retryAfter === void 0 ? [] : [candidate.requested.retryAfter]);
220
+ const retryAfter = selected ? void 0 : Math.min(...retryValues);
221
+ const remaining = selected ? projected.reduce((total, candidate) => total + candidate.evaluated.remaining, 0) : 0;
222
+ const remainingRaw = selected ? projected.reduce((total, candidate) => total + Math.max(0, candidate.evaluated.remainingRaw), 0) : Math.max(...candidates.map((candidate) => candidate.requested.remainingRaw));
223
+ const auxTimestamps = projected.flatMap((candidate) => candidate.evaluated.state.auxTs === void 0 ? [] : [candidate.evaluated.state.auxTs]);
224
+ return {
225
+ state: {
226
+ value: selected || count === 0 ? projected.reduce((total, candidate) => total + candidate.evaluated.state.value, 0) : remainingRaw,
227
+ ts: Math.max(...projected.map((candidate) => candidate.evaluated.state.ts)),
228
+ ...auxTimestamps.length > 0 ? {
229
+ auxValue: projected.reduce((total, candidate) => total + (candidate.evaluated.state.auxValue ?? 0), 0),
230
+ auxTs: Math.max(...auxTimestamps)
231
+ } : {},
232
+ shards: projected.map(({ evaluated, shard }) => ({
233
+ shard,
234
+ state: {
235
+ value: evaluated.state.value,
236
+ ts: evaluated.state.ts,
237
+ auxValue: evaluated.state.auxValue,
238
+ auxTs: evaluated.state.auxTs
239
+ }
240
+ }))
241
+ },
242
+ retryAfter,
243
+ remaining,
244
+ remainingRaw,
245
+ reset: Math.min(...candidates.map((candidate) => candidate.requested.reset)),
246
+ limit: algorithmBudget(algorithm)
247
+ };
248
+ }
102
249
  function calculateTokenBucket(state, config, now, count) {
103
250
  const ratePerMs = config.refillRate / config.interval;
104
251
  const initial = state ?? {
@@ -107,7 +254,8 @@ function calculateTokenBucket(state, config, now, count) {
107
254
  };
108
255
  const elapsed = Math.max(0, now - initial.ts);
109
256
  const nextValue = Math.min(initial.value + elapsed * ratePerMs, config.maxTokens) - count;
110
- const retryAfter = nextValue < 0 ? Math.ceil(-nextValue / ratePerMs) : void 0;
257
+ let retryAfter;
258
+ if (nextValue < 0) retryAfter = count > config.maxTokens ? Number.POSITIVE_INFINITY : Math.ceil(-nextValue / ratePerMs);
111
259
  return {
112
260
  state: {
113
261
  value: nextValue,
@@ -115,6 +263,7 @@ function calculateTokenBucket(state, config, now, count) {
115
263
  },
116
264
  retryAfter,
117
265
  remaining: Math.max(0, Math.floor(nextValue)),
266
+ remainingRaw: nextValue,
118
267
  reset: retryAfter ? now + retryAfter : now,
119
268
  limit: config.maxTokens
120
269
  };
@@ -129,7 +278,8 @@ function calculateFixedWindow(state, config, now, count) {
129
278
  const replenished = Math.min(initial.value + config.limit * elapsedWindows, config.capacity);
130
279
  const ts = initial.ts + elapsedWindows * config.window;
131
280
  const nextValue = replenished - count;
132
- const retryAfter = nextValue < 0 ? ts + config.window * Math.ceil(-nextValue / config.limit) - now : void 0;
281
+ let retryAfter;
282
+ if (nextValue < 0) retryAfter = count > config.capacity ? Number.POSITIVE_INFINITY : ts + config.window * Math.ceil(-nextValue / config.limit) - now;
133
283
  return {
134
284
  state: {
135
285
  value: nextValue,
@@ -137,7 +287,8 @@ function calculateFixedWindow(state, config, now, count) {
137
287
  },
138
288
  retryAfter,
139
289
  remaining: Math.max(0, Math.floor(nextValue)),
140
- reset: ts + config.window,
290
+ remainingRaw: nextValue,
291
+ reset: retryAfter === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : ts + config.window,
141
292
  limit: config.limit
142
293
  };
143
294
  }
@@ -157,7 +308,8 @@ function calculateSlidingWindow(state, config, now, count) {
157
308
  const projectedCurrent = currentCount + count;
158
309
  const projectedUsed = projectedCurrent + previousCount * previousWeight;
159
310
  const remaining = config.limit - projectedUsed;
160
- const retryAfter = remaining < 0 ? Math.max(1, config.window - elapsedInWindow) : void 0;
311
+ let retryAfter;
312
+ if (remaining < 0) retryAfter = count > config.limit ? Number.POSITIVE_INFINITY : Math.max(1, config.window - elapsedInWindow);
161
313
  return {
162
314
  state: {
163
315
  value: projectedCurrent,
@@ -167,10 +319,22 @@ function calculateSlidingWindow(state, config, now, count) {
167
319
  },
168
320
  retryAfter,
169
321
  remaining: Math.max(0, Math.floor(remaining)),
170
- reset: windowStart + config.window,
322
+ remainingRaw: remaining,
323
+ reset: retryAfter === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : windowStart + config.window,
171
324
  limit: config.limit
172
325
  };
173
326
  }
327
+ /**
328
+ * Convert a {@link RatelimitSnapshot} back into the `RatelimitState` shape that
329
+ * {@link calculateRatelimit} consumes.
330
+ *
331
+ * Snapshot `value` is always "tokens left". Fixed window and token bucket store
332
+ * that directly, but sliding window state stores the used count, so it has to be
333
+ * inverted before it can be replayed.
334
+ */
335
+ function snapshotToState(snapshot) {
336
+ return { ...snapshot.state };
337
+ }
174
338
  function alignWindowStart(now, window, start = 0) {
175
339
  const offsetNow = now - start;
176
340
  return start + Math.floor(offsetNow / window) * window;
@@ -178,18 +342,27 @@ function alignWindowStart(now, window, start = 0) {
178
342
 
179
343
  //#endregion
180
344
  //#region src/ratelimit/core/cache.ts
345
+ /**
346
+ * Remembers exhausted shards so a request routed to one skips its read.
347
+ *
348
+ * Entries are per shard, requested count, and reservation mode, never per
349
+ * identifier: a shard can reject a large or ordinary request while still
350
+ * serving a smaller or reserved one, and its peers may still hold tokens.
351
+ */
352
+ const MAX_BLOCK_VARIANTS_PER_IDENTIFIER = 32;
181
353
  var EphemeralBlockCache = class {
182
354
  constructor(cache) {
183
355
  this.cache = cache;
184
356
  }
185
- isBlocked(identifier) {
186
- const reset = this.cache.get(identifier);
357
+ isBlocked(identifier, shard, count, reserve) {
358
+ const key = shardKey(identifier, shard, count, reserve);
359
+ const reset = this.cache.get(key);
187
360
  if (!reset) return {
188
361
  blocked: false,
189
362
  reset: 0
190
363
  };
191
364
  if (reset <= Date.now()) {
192
- this.cache.delete(identifier);
365
+ this.cache.delete(key);
193
366
  return {
194
367
  blocked: false,
195
368
  reset: 0
@@ -200,16 +373,45 @@ var EphemeralBlockCache = class {
200
373
  reset
201
374
  };
202
375
  }
203
- blockUntil(identifier, reset) {
204
- this.cache.set(identifier, reset);
376
+ blockUntil(identifier, shard, count, reserve, reset) {
377
+ if (!Number.isFinite(reset)) return;
378
+ this.pruneExpired();
379
+ const key = shardKey(identifier, shard, count, reserve);
380
+ if (!this.cache.has(key)) this.evictOldestVariant(identifier);
381
+ this.cache.set(key, reset);
205
382
  }
206
383
  clear(identifier) {
207
- this.cache.delete(identifier);
384
+ const prefix = identifierPrefix(identifier);
385
+ for (const key of this.cache.keys()) if (key.startsWith(prefix)) this.cache.delete(key);
386
+ }
387
+ clearAll() {
388
+ this.cache.clear();
208
389
  }
209
390
  size() {
210
391
  return this.cache.size;
211
392
  }
393
+ pruneExpired() {
394
+ const now = Date.now();
395
+ for (const [key, reset] of this.cache) if (reset <= now) this.cache.delete(key);
396
+ }
397
+ evictOldestVariant(identifier) {
398
+ const prefix = identifierPrefix(identifier);
399
+ let variants = 0;
400
+ let oldest;
401
+ for (const key of this.cache.keys()) {
402
+ if (!key.startsWith(prefix)) continue;
403
+ oldest ??= key;
404
+ variants += 1;
405
+ }
406
+ if (variants >= MAX_BLOCK_VARIANTS_PER_IDENTIFIER && oldest) this.cache.delete(oldest);
407
+ }
212
408
  };
409
+ function shardKey(identifier, shard, count, reserve) {
410
+ return `${identifierPrefix(identifier)}${shard}:${count}:${reserve ? 1 : 0}`;
411
+ }
412
+ function identifierPrefix(identifier) {
413
+ return `${identifier.length}:${identifier}:`;
414
+ }
213
415
  function createReadDedupeCache() {
214
416
  const cache = /* @__PURE__ */ new Map();
215
417
  return {
@@ -483,6 +685,7 @@ var Ratelimit = class Ratelimit {
483
685
  blockCache;
484
686
  blockCacheSource;
485
687
  checkCache = createReadDedupeCache();
688
+ cacheGeneration = 0;
486
689
  constructor(config) {
487
690
  this.config = config;
488
691
  this.store = new ConvexRatelimitStore(config.db);
@@ -518,23 +721,22 @@ var Ratelimit = class Ratelimit {
518
721
  }
519
722
  async resetUsedTokens(identifier) {
520
723
  await this.store.deleteStates(this.prefix, identifier);
724
+ this.cacheGeneration += 1;
521
725
  this.checkCache.clear();
522
- if (this.blockCache) this.blockCache.clear(identifier);
726
+ if (this.blockCache) this.blockCache.clear(this.blockKey(identifier));
523
727
  clearProtection(this.prefix, identifier);
524
728
  }
525
729
  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);
730
+ const algorithm = await this.resolveAlgorithm();
731
+ const evaluated = await this.readShards(identifier, algorithm, Array.from({ length: algorithm.shards }, (_, shard) => shard));
531
732
  return {
532
- remaining: Math.max(0, evaluated.remaining),
533
- reset: evaluated.reset,
534
- limit: evaluated.limit
733
+ remaining: Math.max(0, evaluated.reduce((total, shard) => total + shard.evaluated.remaining, 0)),
734
+ reset: Math.min(...evaluated.map((shard) => shard.evaluated.reset)),
735
+ limit: algorithmBudget(algorithm)
535
736
  };
536
737
  }
537
738
  async getValue(identifier, options) {
739
+ const cacheGeneration = this.cacheGeneration;
538
740
  const cacheKey = `${identifier}:${options?.sampleShards ?? 0}`;
539
741
  const cached = this.checkCache.get(cacheKey);
540
742
  if (cached) {
@@ -544,30 +746,70 @@ var Ratelimit = class Ratelimit {
544
746
  const algorithm = await this.resolveAlgorithm();
545
747
  const sampleShards = Math.max(1, Math.min(options?.sampleShards ?? 1, algorithm.shards));
546
748
  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;
749
+ const samples = await this.readShards(identifier, algorithm, shards);
750
+ let sampledRemaining = 0;
751
+ let sampledCapacity = 0;
752
+ let sampledStateValue = 0;
753
+ let sampledAuxValue = 0;
754
+ let latestTs = null;
755
+ let latestAuxTs = null;
756
+ let fullestShard = null;
757
+ let fullestRemaining = Number.NEGATIVE_INFINITY;
758
+ for (const { evaluated, perShard, shard } of samples) {
759
+ sampledRemaining += evaluated.remainingRaw;
760
+ sampledCapacity += algorithmCapacity(perShard);
761
+ sampledStateValue += evaluated.state.value;
762
+ sampledAuxValue += evaluated.state.auxValue ?? 0;
763
+ latestTs = latestTs === null ? evaluated.state.ts : Math.max(latestTs, evaluated.state.ts);
764
+ if (evaluated.state.auxTs !== void 0) latestAuxTs = latestAuxTs === null ? evaluated.state.auxTs : Math.max(latestAuxTs, evaluated.state.auxTs);
765
+ if (evaluated.remainingRaw > fullestRemaining) {
766
+ fullestRemaining = evaluated.remainingRaw;
767
+ fullestShard = shard;
768
+ }
558
769
  }
559
- const result = best ?? {
560
- value: algorithm.kind === "tokenBucket" ? algorithm.maxTokens : algorithm.limit,
561
- ts: now,
770
+ const result = fullestShard === null ? {
771
+ value: algorithmCapacity(algorithm),
772
+ ts: Date.now(),
562
773
  shard: 0,
563
- config: algorithm
774
+ config: algorithm,
775
+ state: {
776
+ value: algorithm.kind === "slidingWindow" ? 0 : algorithmCapacity(algorithm),
777
+ ts: Date.now(),
778
+ shards: []
779
+ }
780
+ } : {
781
+ value: scaleToGlobal(sampledRemaining, sampledCapacity, algorithmCapacity(algorithm)),
782
+ ts: latestTs ?? Date.now(),
783
+ shard: fullestShard,
784
+ config: algorithm,
785
+ state: {
786
+ value: scaleToGlobal(sampledStateValue, sampledCapacity, algorithmCapacity(algorithm)),
787
+ ts: latestTs ?? Date.now(),
788
+ ...algorithm.kind === "slidingWindow" ? {
789
+ auxValue: scaleToGlobal(sampledAuxValue, sampledCapacity, algorithmCapacity(algorithm)),
790
+ auxTs: latestAuxTs ?? (latestTs ?? Date.now()) - algorithm.window
791
+ } : {},
792
+ shards: samples.map(({ evaluated, shard }) => ({
793
+ shard,
794
+ state: {
795
+ value: evaluated.state.value,
796
+ ts: evaluated.state.ts,
797
+ auxValue: evaluated.state.auxValue,
798
+ auxTs: evaluated.state.auxTs
799
+ }
800
+ }))
801
+ }
564
802
  };
565
- this.checkCache.set(cacheKey, Promise.resolve(result));
803
+ if (cacheGeneration === this.cacheGeneration) this.checkCache.set(cacheKey, Promise.resolve(result));
566
804
  return result;
567
805
  }
568
806
  async setDynamicLimit(options) {
569
807
  if (!this.dynamicLimits) throw new Error("dynamicLimits must be enabled in the Ratelimit constructor to use setDynamicLimit()");
808
+ applyDynamicLimit(this.limiter, options.limit === false ? null : options.limit);
570
809
  await this.store.setDynamicLimit(this.prefix, options.limit);
810
+ this.cacheGeneration += 1;
811
+ this.checkCache.clear();
812
+ this.blockCache?.clearAll();
571
813
  }
572
814
  async getDynamicLimit() {
573
815
  if (!this.dynamicLimits) throw new Error("dynamicLimits must be enabled in the Ratelimit constructor to use getDynamicLimit()");
@@ -584,7 +826,22 @@ var Ratelimit = class Ratelimit {
584
826
  value: v.number(),
585
827
  ts: v.number(),
586
828
  shard: v.number(),
587
- config: v.any()
829
+ config: v.any(),
830
+ state: v.object({
831
+ value: v.number(),
832
+ ts: v.number(),
833
+ auxValue: v.optional(v.number()),
834
+ auxTs: v.optional(v.number()),
835
+ shards: v.optional(v.array(v.object({
836
+ shard: v.number(),
837
+ state: v.object({
838
+ value: v.number(),
839
+ ts: v.number(),
840
+ auxValue: v.optional(v.number()),
841
+ auxTs: v.optional(v.number())
842
+ })
843
+ })))
844
+ })
588
845
  }),
589
846
  handler: async (ctx, args) => {
590
847
  const identifier = await resolveIdentifier(options?.identifier, ctx, args.identifier);
@@ -606,6 +863,7 @@ var Ratelimit = class Ratelimit {
606
863
  });
607
864
  }
608
865
  async evaluate(identifier, request, consume) {
866
+ const cacheGeneration = this.cacheGeneration;
609
867
  const deniedValue = this.enableProtection ? pickDeniedValue({
610
868
  prefix: this.prefix,
611
869
  identifier,
@@ -615,7 +873,7 @@ var Ratelimit = class Ratelimit {
615
873
  if (deniedValue) return {
616
874
  success: false,
617
875
  ok: false,
618
- limit: this.rawLimit(this.limiter),
876
+ limit: algorithmBudget(this.limiter),
619
877
  remaining: 0,
620
878
  reset: Date.now() + 6e4,
621
879
  pending: Promise.resolve(),
@@ -623,47 +881,73 @@ var Ratelimit = class Ratelimit {
623
881
  deniedValue
624
882
  };
625
883
  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
- }
884
+ const count = normalizeCount(request);
885
+ const reserveRequested = Boolean(request?.reserve);
886
+ if (count > maximumRequestSize(algorithm, reserveRequested)) return {
887
+ success: false,
888
+ ok: false,
889
+ limit: algorithmBudget(algorithm),
890
+ remaining: 0,
891
+ reset: 0,
892
+ pending: Promise.resolve(),
893
+ reason: "requestTooLarge"
894
+ };
895
+ const picked = pickCandidateShards(algorithm.shards);
896
+ const attempted = /* @__PURE__ */ new Set();
897
+ let blockedUntil = Number.POSITIVE_INFINITY;
898
+ const findOpen = (shards) => {
899
+ const open = [];
900
+ for (const shard of shards) {
901
+ attempted.add(shard);
902
+ const blocked = this.blockCache && count > 0 ? this.blockCache.isBlocked(this.blockKey(identifier), shard, count, reserveRequested) : void 0;
903
+ if (blocked?.blocked) blockedUntil = Math.min(blockedUntil, blocked.reset);
904
+ else open.push(shard);
905
+ }
906
+ return open;
907
+ };
641
908
  const now = Date.now();
642
- const candidates = await this.evaluateCandidates(identifier, algorithm, now, count, reserveRequested);
909
+ const candidates = await this.evaluateCandidates(identifier, algorithm, findOpen(picked), now, count, reserveRequested);
910
+ if (!candidates.some((candidate) => candidate.success)) {
911
+ const fallback = Array.from({ length: algorithm.shards }, (_, shard) => shard).filter((shard) => !attempted.has(shard));
912
+ candidates.push(...await this.evaluateCandidates(identifier, algorithm, findOpen(fallback), now, count, reserveRequested));
913
+ }
914
+ if (candidates.length === 0) return {
915
+ success: false,
916
+ ok: false,
917
+ limit: algorithmBudget(algorithm),
918
+ remaining: 0,
919
+ reset: blockedUntil,
920
+ pending: Promise.resolve(),
921
+ reason: "cacheBlock"
922
+ };
923
+ if (consume && this.blockCache && count > 0 && cacheGeneration === this.cacheGeneration) {
924
+ for (const candidate of candidates) if (!candidate.success) this.blockCache.blockUntil(this.blockKey(identifier), candidate.shard, count, reserveRequested, now + (candidate.retryAfter ?? 1));
925
+ }
643
926
  const successful = candidates.filter((candidate) => candidate.success);
644
927
  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}`);
928
+ const best = successful.sort((a, b) => b.evaluated.remainingRaw - a.evaluated.remainingRaw)[0];
929
+ if (consume && count !== 0) {
930
+ await this.store.upsertState({
931
+ name: this.prefix,
932
+ key: identifier,
933
+ shard: best.shard,
934
+ state: best.evaluated.state
935
+ });
936
+ this.cacheGeneration += 1;
937
+ }
653
938
  clearProtection(this.prefix, identifier);
654
939
  this.checkCache.clear();
655
940
  return {
656
941
  success: true,
657
942
  ok: true,
658
- limit: best.evaluated.limit,
659
- remaining: best.evaluated.remaining,
943
+ limit: algorithmBudget(algorithm),
944
+ remaining: Math.max(0, Math.floor(scaleToGlobal(best.evaluated.remainingRaw, algorithmCapacity(shardAlgorithm(algorithm, best.shard)), algorithmCapacity(algorithm)))),
660
945
  reset: best.evaluated.reset,
661
946
  pending: Promise.resolve()
662
947
  };
663
948
  }
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);
949
+ const retryAfter = (candidates.filter((candidate) => candidate.retryAfter !== void 0).sort((a, b) => (a.retryAfter ?? Number.MAX_SAFE_INTEGER) - (b.retryAfter ?? Number.MAX_SAFE_INTEGER))[0] ?? candidates[0]).retryAfter ?? 1;
950
+ const reset = Math.min(now + retryAfter, blockedUntil);
667
951
  if (consume && this.enableProtection) recordRatelimitFailure({
668
952
  prefix: this.prefix,
669
953
  identifier,
@@ -673,38 +957,48 @@ var Ratelimit = class Ratelimit {
673
957
  return {
674
958
  success: false,
675
959
  ok: false,
676
- limit: failure.evaluated.limit,
960
+ limit: algorithmBudget(algorithm),
677
961
  remaining: 0,
678
962
  reset,
679
963
  pending: Promise.resolve()
680
964
  };
681
965
  }
682
- async evaluateCandidates(identifier, algorithm, now, count, reserveRequested) {
683
- const shards = pickCandidateShards(algorithm.shards);
684
- const result = [];
685
- for (const shard of shards) {
966
+ blockKey(identifier) {
967
+ return `${this.prefix}:${identifier}`;
968
+ }
969
+ async readShards(identifier, algorithm, shards) {
970
+ const samples = await Promise.all(shards.map(async (shard) => ({
971
+ perShard: shardAlgorithm(algorithm, shard),
972
+ shard,
973
+ state: normalizeState(await this.store.getState(this.prefix, identifier, shard))
974
+ })));
975
+ const now = Date.now();
976
+ return samples.map(({ perShard, shard, state }) => ({
977
+ shard,
978
+ perShard,
979
+ evaluated: calculateRatelimit(state, perShard, now, 0)
980
+ }));
981
+ }
982
+ async evaluateCandidates(identifier, algorithm, shards, now, count, reserveRequested) {
983
+ return Promise.all(shards.map(async (shard) => {
984
+ const perShard = shardAlgorithm(algorithm, shard);
686
985
  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({
986
+ const evaluated = calculateRatelimit(state, perShard, now, count);
987
+ const retryAfter = getRequestRetryAfter(evaluated, perShard, now, count, reserveRequested);
988
+ return {
691
989
  shard,
692
990
  state,
693
991
  evaluated,
694
- success
695
- });
696
- }
697
- return result;
992
+ retryAfter,
993
+ success: retryAfter === void 0
994
+ };
995
+ }));
698
996
  }
699
997
  async resolveAlgorithm() {
700
998
  if (!this.dynamicLimits) return this.limiter;
701
999
  const dynamicLimit = await this.store.getDynamicLimit(this.prefix);
702
1000
  return applyDynamicLimit(this.limiter, dynamicLimit);
703
1001
  }
704
- rawLimit(algorithm) {
705
- if (algorithm.kind === "tokenBucket") return algorithm.maxTokens;
706
- return algorithm.limit;
707
- }
708
1002
  async runWithTimeout(operation) {
709
1003
  if (this.timeout <= 0) return operation();
710
1004
  const startedAt = Date.now();
@@ -729,6 +1023,39 @@ var Ratelimit = class Ratelimit {
729
1023
  };
730
1024
  }
731
1025
  };
1026
+ function maximumRequestSize(algorithm, reserveRequested) {
1027
+ let maximum = 0;
1028
+ for (let shard = 0; shard < algorithm.shards; shard += 1) {
1029
+ const perShard = shardAlgorithm(algorithm, shard);
1030
+ maximum = Math.max(maximum, algorithmCapacity(perShard) + reservationHeadroom(perShard, reserveRequested));
1031
+ }
1032
+ return maximum;
1033
+ }
1034
+ function reservationHeadroom(algorithm, reserveRequested) {
1035
+ if (!reserveRequested || algorithm.kind === "slidingWindow") return 0;
1036
+ return algorithm.maxReserved ?? Number.POSITIVE_INFINITY;
1037
+ }
1038
+ function getRequestRetryAfter(evaluated, algorithm, now, count, reserveRequested) {
1039
+ const reservation = reservationHeadroom(algorithm, reserveRequested);
1040
+ if (count > algorithmCapacity(algorithm) + reservation) return Number.POSITIVE_INFINITY;
1041
+ if (!reserveRequested || evaluated.retryAfter === void 0 || algorithm.kind === "slidingWindow") return evaluated.retryAfter;
1042
+ if (algorithm.maxReserved === void 0) return;
1043
+ const excessDebt = -evaluated.remainingRaw - Math.max(0, algorithm.maxReserved);
1044
+ if (excessDebt <= 0) return;
1045
+ if (algorithm.kind === "tokenBucket") return Math.ceil(excessDebt / (algorithm.refillRate / algorithm.interval));
1046
+ return Math.max(1, evaluated.state.ts + algorithm.window * Math.ceil(excessDebt / algorithm.limit) - now);
1047
+ }
1048
+ /**
1049
+ * Project the tokens left on the sampled shards onto the full budget.
1050
+ *
1051
+ * Sampling every shard adds up to the real global balance, so it is returned
1052
+ * untouched. A partial sample is scaled by the share of the budget it covers,
1053
+ * which assumes the unread shards are drained like the ones that were read.
1054
+ */
1055
+ function scaleToGlobal(remaining, sampledBudget, totalBudget) {
1056
+ if (sampledBudget <= 0 || sampledBudget >= totalBudget) return remaining;
1057
+ return remaining * (totalBudget / sampledBudget);
1058
+ }
732
1059
  function normalizeCount(request) {
733
1060
  if (!request) return 1;
734
1061
  const value = request.rate ?? request.count ?? 1;
@@ -853,4 +1180,4 @@ const DAY = 24 * HOUR;
853
1180
  const WEEK = 7 * DAY;
854
1181
 
855
1182
  //#endregion
856
- export { DAY, HOUR, MINUTE, RATE_LIMIT_DYNAMIC_TABLE, RATE_LIMIT_HIT_TABLE, RATE_LIMIT_STATE_TABLE, Ratelimit, RatelimitPlugin, SECOND, WEEK, applyDynamicLimit, calculateRatelimit, fixedWindow, slidingWindow, toMs, tokenBucket };
1183
+ export { DAY, HOUR, MINUTE, RATE_LIMIT_DYNAMIC_TABLE, RATE_LIMIT_HIT_TABLE, RATE_LIMIT_STATE_TABLE, Ratelimit, RatelimitPlugin, SECOND, WEEK, applyDynamicLimit, calculateRatelimit, fixedWindow, slidingWindow, snapshotToState, toMs, tokenBucket };