@lunora/ratelimit 1.0.0-alpha.14 → 1.0.0-alpha.140

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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  </a>
8
8
 
9
- <h3 align="center">Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, sharding, pluggable stores, and procedure middleware</h3>
9
+ <h3 align="center">Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, pluggable stores, and procedure middleware</h3>
10
10
 
11
11
  <!-- END_PACKAGE_OG_IMAGE_PLACEHOLDER -->
12
12
 
@@ -34,7 +34,7 @@
34
34
 
35
35
  ---
36
36
 
37
- Rate limiting for Lunora: token-bucket, fixed-window, and sliding-window algorithms, a deny list, optional sharding for hot limits, pluggable stores (in-memory, SQL, or the Lunora ORM), and procedure middleware that rides the `.use()` chain.
37
+ Rate limiting for Lunora: token-bucket, fixed-window, and sliding-window algorithms, a deny list, pluggable stores (in-memory, SQL, or the Lunora ORM), and procedure middleware that rides the `.use()` chain.
38
38
 
39
39
  Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX.
40
40
 
@@ -59,37 +59,68 @@ to a procedure's `.use(...)` chain. The procedure builders (`mutation`, …) com
59
59
  from your app's generated `_generated/server` module, not from a package.
60
60
 
61
61
  ```ts
62
- import { RateLimiter, rateLimit } from "@lunora/ratelimit";
62
+ import { dbRateLimit } from "@lunora/ratelimit";
63
63
 
64
64
  import { mutation } from "./_generated/server";
65
65
 
66
- const limiter = new RateLimiter({
67
- config: {
68
- login: { kind: "fixed window", period: 60_000, rate: 5 },
69
- send: { kind: "token bucket", period: 1_000, rate: 10 },
70
- },
71
- });
72
-
66
+ const config = {
67
+ login: { kind: "fixed window", period: 60_000, rate: 5 },
68
+ send: { kind: "token bucket", period: 1_000, rate: 10 },
69
+ };
70
+
71
+ // `dbRateLimit` builds the limiter per call, backed by a Lunora table via
72
+ // `ctx.db`, so the count is durable. A `RateLimiter` built with no explicit
73
+ // `store` falls back to `createMemoryStore` (a per-instance in-memory Map) —
74
+ // fine for a limit that only ever needs to hold within one Durable Object
75
+ // instance, but a limiter like `login` needs a durable store or it stops
76
+ // enforcing the configured rate the moment the DO instance is sharded,
77
+ // replicated, or recreated.
73
78
  // As procedure middleware — throws a structural LunoraError (429/403) on rejection.
74
- export const send = mutation.use(rateLimit(limiter, "send", { key: (ctx) => ctx.auth.userId })).mutation(async ({ ctx }) => {
79
+ // `key` must resolve to a string — a resolver returning `undefined` throws
80
+ // rather than silently sharing one bucket across every keyless caller.
81
+ export const send = mutation.use(dbRateLimit(config, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" })).mutation(async ({ ctx }) => {
75
82
  // …
76
83
  });
77
84
  ```
78
85
 
79
- Or call the limiter directly:
86
+ Or call the limiter directly, inside a mutation/action so `ctx.db` is available.
87
+ A login limiter belongs in an **action**: a mutation's `ctx.db` writes ride its
88
+ storage transaction, so a handler that throws (a wrong password) rolls the
89
+ consumed unit back and every failed attempt is free. An action's writes commit
90
+ on their own, so the charge stays whether or not the handler throws.
80
91
 
81
92
  ```ts
82
- const status = await limiter.limit("send", { key: userId });
93
+ import { RateLimiter, RateLimitError, createDbStore } from "@lunora/ratelimit";
94
+
95
+ import { action } from "./_generated/server";
83
96
 
84
- if (!status.ok) {
85
- // status.retryAfter is milliseconds until the request would succeed.
86
- // status.reason is "rate" or "deny".
87
- }
97
+ export const login = action.action(async ({ ctx, args }) => {
98
+ const limiter = new RateLimiter({ config, store: createDbStore({ db: ctx.db }) });
99
+ const status = await limiter.limit("login", { key: args.email });
100
+
101
+ if (!status.ok) {
102
+ // Stop here — `limit` returns a failing status by default rather than
103
+ // throwing, so falling through authenticates the rejected attempt.
104
+ // status.retryAfter is milliseconds until the request would succeed.
105
+ // status.reason is "rate" or "deny".
106
+ throw new RateLimitError(status);
107
+ }
108
+
109
+ await authenticate(args);
110
+ });
88
111
  ```
89
112
 
90
113
  For durable per-DO state inside a procedure, back the limiter with a Lunora
91
114
  table via `dbRateLimit(config, name, options)`, or supply a `store`
92
- (`createMemoryStore` / `createSqlStore` / `createDbStore`).
115
+ (`createSqlStore` / `createDbStore`). `createMemoryStore` stays the default —
116
+ correct inside a single Durable Object — but constructing a `RateLimiter` with
117
+ no explicit `store` now warns once so that choice is visible instead of
118
+ silent.
119
+
120
+ A key that is an IPv6 address (for example `ctx.ip`) is bucketed by its /64
121
+ prefix, and an IPv4-mapped address by the IPv4 address it wraps. An IPv6 client
122
+ usually controls a whole /64, so a per-address bucket would let it pick a fresh
123
+ address for every request. A deny-list IPv6 entry bans its /64.
93
124
 
94
125
  > This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs)**.
95
126
 
package/dist/index.d.mts CHANGED
@@ -19,18 +19,6 @@ interface RateLimitConfig {
19
19
  period: number;
20
20
  /** Tokens granted per `period`. */
21
21
  rate: number;
22
- /**
23
- * Split a hot limit across N independent sub-buckets to avoid a single
24
- * contended key/Durable Object. Each shard enforces `rate / shards` (and
25
- * `capacity / shards`); a request is routed to a shard via a deterministic
26
- * hash of `(name, key)`, so the same key always lands on the same shard and
27
- * a single key's effective throughput is exactly `rate / shards`. Aggregate
28
- * throughput across many distinct keys approaches `rate` as keys spread
29
- * uniformly across shards. Reserve it for high-volume limits where
30
- * contention bites; leave unset (one bucket) otherwise. Must be a positive
31
- * integer — `1` is equivalent to unset.
32
- */
33
- shards?: number;
34
22
  /**
35
23
  * Phase offset in epoch milliseconds for windowed algorithms — windows
36
24
  * align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
@@ -92,6 +80,30 @@ interface RateLimitStore {
92
80
  delete: (storageKey: string) => Promise<void> | void;
93
81
  get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
94
82
  set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
83
+ /**
84
+ * Optional conditional write: persist `value` only while the stored value is
85
+ * still `prior` (what `get` returned for this consume, `undefined` for no
86
+ * row), and resolve `false` when it no longer is. When present the limiter
87
+ * writes through it instead of `set`, and a `false` makes it read and decide
88
+ * again rather than admit off a stale read.
89
+ *
90
+ * A store whose `get` and write cannot be interleaved by another consume
91
+ * (memory, SQL: synchronous) needs none. One whose calls can be
92
+ * (`createDbStore`: every call is awaited I/O) must have it.
93
+ */
94
+ setIf?: (storageKey: string, value: RateLimitValue, prior: RateLimitValue | undefined) => Promise<boolean>;
95
+ /**
96
+ * Optional eviction hint, called by the limiter after each successful write.
97
+ * Examine up to `limit` entries from the store's oldest end and remove those
98
+ * whose `ts` is at or before `olderThanTs`, except entries with a negative
99
+ * `value`: a reserved debt is kept, whatever its age. `limit` caps entries
100
+ * examined, not entries removed. Omit it when the store's rows must persist.
101
+ *
102
+ * `owns` says whether a storage key belongs to the limiter asking for the
103
+ * sweep. A store shared with another limiter must leave the other limiter's
104
+ * keys alone: its horizon is not this one's. Absent means every key is owned.
105
+ */
106
+ sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => void;
95
107
  }
96
108
  /** Inputs to {@link evaluate}. */
97
109
  interface EvaluateOptions {
@@ -135,11 +147,15 @@ interface RateLimiterOptions<Names extends string> {
135
147
  denyList?: Iterable<string>;
136
148
  /**
137
149
  * Optional key normalizer applied to every incoming `args.key` (the
138
- * deny-list check, the storage key, and downstream shard selection all see
139
- * the normalized form). Use for case-folding, trimming, or canonicalizing
140
- * IPs/emails so equivalent inputs share a single bucket. The deny-list
150
+ * deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
151
+ * emails so equivalent inputs share a single bucket. The deny-list
141
152
  * itself is consulted as-is; normalize the deny-list entries up front to
142
153
  * match.
154
+ *
155
+ * After it, a key that is an IPv6 address is always collapsed to its /64
156
+ * (`2001:db8:1:2::/64`) and an IPv4-mapped one to its IPv4 address, so a
157
+ * `ctx.ip`-keyed limit cannot be dodged by rotating addresses within one
158
+ * delegated prefix. A deny-list IPv6 entry therefore bans its whole /64.
143
159
  */
144
160
  normalize?: (key: string) => string;
145
161
  /** Clock injection for tests. Defaults to `Date.now`. */
@@ -158,7 +174,10 @@ declare class RateLimiter<Names extends string = string> {
158
174
  private readonly denyList;
159
175
  private readonly normalize;
160
176
  private readonly now;
177
+ private readonly retention;
161
178
  private readonly store;
179
+ /** Whether a stored key is one of this limiter's names; a sweep only evicts those. */
180
+ private readonly owns;
162
181
  constructor(options: RateLimiterOptions<Names>);
163
182
  /** Peek at whether a request would be permitted without consuming. */
164
183
  check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
@@ -166,9 +185,7 @@ declare class RateLimiter<Names extends string = string> {
166
185
  * Read the current config and the units admittable right now for a
167
186
  * `(name, key)` pair. The value is projected forward to the current clock
168
187
  * (token-bucket refill, fixed-window rollover, sliding-window decay), not
169
- * the last persisted figure. For a sharded limit it reads only the single
170
- * shard `limit()`/`run()` would route this key to — the sibling shards are
171
- * never touched by this key, so summing them would over-report.
188
+ * the last persisted figure.
172
189
  */
173
190
  getValue(name: Names, args?: {
174
191
  key?: string;
@@ -183,6 +200,17 @@ declare class RateLimiter<Names extends string = string> {
183
200
  reset(name: Names, args?: {
184
201
  key?: string;
185
202
  }): Promise<void>;
203
+ /**
204
+ * Read, evaluate and write one consume. A store that reports the write lost
205
+ * (`setIf` resolved `false`: another consume changed the value since this one
206
+ * read it) gets a fresh read and a fresh decision, so the admission always
207
+ * rests on the value the write replaced. Bounded: each lost round means some
208
+ * other consume was admitted (a refusal writes nothing), and past
209
+ * {@link MAX_DECIDE_ATTEMPTS} the call is refused with `retryAfter: 0` rather
210
+ * than admitted.
211
+ */
212
+ private decide;
213
+ private normalizeKey;
186
214
  private resolve;
187
215
  private run;
188
216
  }
@@ -202,7 +230,15 @@ interface RateLimitMiddlewareOptions<Context> {
202
230
  * — note that a failing limiter then permits every request through.
203
231
  */
204
232
  failOpen?: boolean;
205
- /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
233
+ /**
234
+ * Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
235
+ *
236
+ * A resolver that returns `undefined` is a config bug, not a global limit:
237
+ * the middleware throws `INTERNAL` rather than silently pooling every
238
+ * keyless caller (e.g. every anonymous user) into one shared bucket. Fold
239
+ * the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
240
+ * shared bucket is a visible choice.
241
+ */
206
242
  key?: (context: Context) => string | undefined;
207
243
  /** Override the error message thrown on rejection. */
208
244
  message?: string;
@@ -211,7 +247,7 @@ interface RateLimitMiddlewareOptions<Context> {
211
247
  * Procedure middleware that enforces a named rate limit before the handler
212
248
  * runs. Attach it with `.use()`. On rejection it throws a structural
213
249
  * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
214
- * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
250
+ * hits) carrying `data.retryAfterMs` — the runtime maps it to the
215
251
  * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
216
252
  *
217
253
  * **Failure policy:** if resolving or invoking the limiter throws for a genuine
@@ -230,6 +266,9 @@ declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: stri
230
266
  * In-memory store. State lives for the lifetime of the process (or, inside a
231
267
  * Durable Object, the instance) — adequate for single-DO limits but not shared
232
268
  * across instances. Use {@link createSqlStore} for durable per-DO state.
269
+ *
270
+ * A store may be shared by several limiters: each sweep evicts only the keys its
271
+ * limiter owns, by that limiter's horizon (see `RateLimitStore.sweep`).
233
272
  */
234
273
  declare const createMemoryStore: () => RateLimitStore;
235
274
  /**
@@ -250,34 +289,14 @@ interface SqlStoreOptions {
250
289
  * SQLite-backed store for durable, per-DO rate-limit state. Persists each
251
290
  * `(name, key)` pair as one row so limits survive hibernation and eviction.
252
291
  *
253
- * **Atomicity:** the store does not wrap individual operations in an explicit
254
- * SQL transaction. Inside a Durable Object the DO's input gate serializes
255
- * every RPC call against the storage, so the limiter's read-modify-write
256
- * sequence runs to completion without interleaving — this is the same
257
- * guarantee the surrounding `evaluate()` step depends on. **Outside a DO**
258
- * (e.g. driving `createSqlStore` from a long-lived `node:sqlite` connection in
259
- * tests or a custom host) the caller is responsible for serialization; the
260
- * SQL surface used here (`exec`) is not a substitute for transactional
261
- * isolation across concurrent invocations.
292
+ * **Atomicity:** `exec` is synchronous and the limiter does not yield between a
293
+ * synchronous store's read and its write, so one consume's read, decision and
294
+ * write run as a single JavaScript step. No other call on the same isolate can
295
+ * interleave, which is what makes concurrent consumes admit exactly the
296
+ * configured rate. Two isolates sharing one SQLite file (not a Durable Object
297
+ * case) are not covered; serialize those yourself.
262
298
  */
263
299
  declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
264
- /**
265
- * The slice of an index-range builder the store uses. Mirrors `@lunora/server`'s
266
- * `IndexRangeBuilder` field-for-field so the real `ctx.db` query builder is
267
- * assignable; only `eq` is exercised.
268
- */
269
- interface RateLimitDatabaseIndexRange {
270
- eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
271
- gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
272
- gte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
273
- lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
274
- lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
275
- }
276
- /** The slice of a `ctx.db` table query the store relies on. */
277
- interface RateLimitDatabaseQuery {
278
- first: () => Promise<Record<string, unknown> | null>;
279
- withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
280
- }
281
300
  /**
282
301
  * The READ slice — everything the store needs to answer `RateLimiter.getValue`
283
302
  * and `check`, which the docs describe as projecting the stored value forward to
@@ -288,9 +307,16 @@ interface RateLimitDatabaseQuery {
288
307
  * at all — every remaining-quota display had to cast, and a cast that appears
289
308
  * often enough stops carrying information. The distinction was already in the
290
309
  * methods: `getValue`/`check` read, `limit`/`reset` write.
310
+ *
311
+ * `findFirst` rather than `query(table).withIndex(...)`: a `.global()` table's
312
+ * reader has no `withIndex`, so the indexed builder failed every read there.
313
+ * `findFirst` routes to whichever backend owns the table, and an equality on the
314
+ * key column still uses its index.
291
315
  */
292
316
  interface RateLimitDatabaseReader {
293
- query: (table: string) => RateLimitDatabaseQuery;
317
+ findFirst: (table: string, args?: {
318
+ where?: Record<string, unknown>;
319
+ }) => Promise<Record<string, unknown> | null>;
294
320
  }
295
321
  /**
296
322
  * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
@@ -300,14 +326,20 @@ interface RateLimitDatabaseReader {
300
326
  */
301
327
  interface RateLimitDatabase extends RateLimitDatabaseReader {
302
328
  delete: <T extends string>(id: Id<T>) => Promise<void>;
303
- insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
304
- patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
329
+ insert: <T extends string>(table: T, document: Record<string, unknown>, options?: {
330
+ clientId?: string;
331
+ }) => Promise<Id<T>>;
332
+ patchIf: <T extends string>(table: T, id: Id<T>, args: {
333
+ patch: Record<string, unknown>;
334
+ where: Record<string, unknown>;
335
+ }) => Promise<{
336
+ applied: boolean;
337
+ doc: Record<string, unknown> | null;
338
+ }>;
305
339
  }
306
- /** The table/column/index knobs shared by the read-only and read-write stores. */
340
+ /** The table/column knobs shared by the read-only and read-write stores. */
307
341
  interface DatabaseStoreLocation {
308
- /** Index that resolves a row by its key column. Defaults to `by_key`. */
309
- index?: string;
310
- /** Column storing the opaque key. Defaults to `key`. */
342
+ /** Column storing the opaque key. Defaults to `key`. Index it: every consume reads by it. */
311
343
  keyField?: string;
312
344
  /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
313
345
  table?: string;
@@ -321,9 +353,10 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
321
353
  db: RateLimitDatabaseReader;
322
354
  }
323
355
  /**
324
- * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
325
- * inside a procedure (the procedure context exposes no raw SQL). Declare a
326
- * table with the key column and its index, e.g.
356
+ * Store backed by a Lunora table through `ctx.db`, for durable limits inside a
357
+ * procedure (the procedure context exposes no raw SQL). Works on a shard-local
358
+ * table and on a `.global()` one. Declare a table with the key column and its
359
+ * index, e.g.
327
360
  *
328
361
  * ```ts
329
362
  * rateLimits: defineTable({
@@ -334,8 +367,22 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
334
367
  * }).index("by_key", ["key"])
335
368
  * ```
336
369
  *
337
- * Each operation is a read-then-write; inside a mutation/action that pair runs
338
- * under the DO's input gate, so it is atomic against concurrent calls.
370
+ * **Atomic against concurrent consumes.** `setIf` writes only if the row still
371
+ * holds the value `get` returned: an existing row through `ctx.db.patchIf` with
372
+ * that value as its `where`, a new row through an `insert` under an id derived
373
+ * from the key, which a concurrent creator's insert collides with. A consume
374
+ * that loses either race gets `false` back and the limiter reads and decides
375
+ * again, so a limit of 1 admits one caller however many arrive together — on a
376
+ * `.global()` table too, where D1 calls do not hold the Durable Object's input
377
+ * gate.
378
+ *
379
+ * **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
380
+ * its storage transaction, so a handler that throws after `limit()` rolls the
381
+ * consumed unit back with everything else — inside a mutation this store counts
382
+ * successful calls, not attempts. To charge every attempt (a login limiter),
383
+ * consume from an action, where each write commits on its own, or return a
384
+ * failure value from the mutation instead of throwing. A `.global()` table's
385
+ * row lives in D1, outside the DO transaction, so there the unit stays spent.
339
386
  */
340
387
  declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
341
388
  /**
@@ -364,12 +411,17 @@ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOption
364
411
  * on the DO the procedure runs on). Every query/mutation/action ctx exposes a
365
412
  * compatible `db`, so it slots straight into a `.use(...)` chain.
366
413
  *
367
- * Pass `options.store` to point at a non-default backing table/index/key column
368
- * (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
414
+ * Pass `options.store` to point at a non-default backing table/key column
415
+ * (defaults: table `rateLimits`, key column `key`); the rest of
369
416
  * `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
370
417
  * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
371
418
  * autocompletes to its declared limit names.
372
419
  *
420
+ * On a mutation the consumed unit commits with the handler: a handler that
421
+ * throws rolls it back, so a failed call costs nothing. Attach it to an action
422
+ * (whose writes commit independently) when failed attempts must count — see
423
+ * {@link createDatabaseStore}.
424
+ *
373
425
  * Re-exported as `dbRateLimit` from the package root.
374
426
  *
375
427
  * ```ts
@@ -420,7 +472,7 @@ interface RatelimitApiContext<Context> {
420
472
  *
421
473
  * ```ts
422
474
  * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
423
- * const c = initLunora.dataModel&lt;DataModel>().create();
475
+ * const c = initLunora.dataModel<DataModel>().create();
424
476
  * export const send = c.mutation
425
477
  * .use(ratelimitPlugin(limiter).middleware!)
426
478
  * .mutation(async ({ ctx, args }) => {
@@ -439,5 +491,50 @@ interface RatelimitApiContext<Context> {
439
491
  * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
440
492
  */
441
493
  declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
494
+ /** A budget bound to one named limit — check before the call, record after it. */
495
+ interface TokenBudget {
496
+ /**
497
+ * Peek at the budget before spending. `ok: false` means it is exhausted:
498
+ * refuse the call, and `retryAfter` says when it refills. Consumes nothing.
499
+ */
500
+ check: (key: string) => Promise<RateLimitStatus>;
501
+ /**
502
+ * Charge the tokens a call actually used. Always call it, including when the
503
+ * call THREW — a failed generation that consumed input tokens still has to be
504
+ * paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
505
+ * nothing.
506
+ *
507
+ * The charge is a reservation, so it may take the bucket negative: the tokens
508
+ * are already spent, and refusing to record them would let a single oversized
509
+ * call escape the budget entirely.
510
+ */
511
+ record: (key: string, tokens: number) => Promise<RateLimitStatus>;
512
+ }
513
+ /**
514
+ * Bind a {@link TokenBudget} to one of a limiter's named limits.
515
+ *
516
+ * ```ts
517
+ * const budget = tokenBudget(limiter, "tokens");
518
+ * const allowed = await budget.check(userId);
519
+ *
520
+ * if (!allowed.ok) {
521
+ * throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
522
+ * }
523
+ *
524
+ * try {
525
+ * const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
526
+ *
527
+ * await budget.record(userId, usage?.totalTokens ?? 0);
528
+ *
529
+ * return text;
530
+ * } catch (error) {
531
+ * // The prompt was still sent — charge what is known, then rethrow.
532
+ * await budget.record(userId, estimatedInputTokens);
533
+ *
534
+ * throw error;
535
+ * }
536
+ * ```
537
+ */
538
+ declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
442
539
  declare const VERSION = "0.0.0";
443
- export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseIndexRange as RateLimitDbIndexRange, type RateLimitDatabaseQuery as RateLimitDbQuery, type RateLimitDatabaseReader as RateLimitDbReader, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type ReadOnlyDatabaseStoreOptions as ReadOnlyDbStoreOptions, type SqlLike, type SqlStoreOptions, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createReadOnlyDatabaseStore as createReadOnlyDbStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin };
540
+ export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseReader as RateLimitDbReader, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type ReadOnlyDatabaseStoreOptions as ReadOnlyDbStoreOptions, type SqlLike, type SqlStoreOptions, type TokenBudget, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createReadOnlyDatabaseStore as createReadOnlyDbStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin, tokenBudget };
package/dist/index.d.ts CHANGED
@@ -19,18 +19,6 @@ interface RateLimitConfig {
19
19
  period: number;
20
20
  /** Tokens granted per `period`. */
21
21
  rate: number;
22
- /**
23
- * Split a hot limit across N independent sub-buckets to avoid a single
24
- * contended key/Durable Object. Each shard enforces `rate / shards` (and
25
- * `capacity / shards`); a request is routed to a shard via a deterministic
26
- * hash of `(name, key)`, so the same key always lands on the same shard and
27
- * a single key's effective throughput is exactly `rate / shards`. Aggregate
28
- * throughput across many distinct keys approaches `rate` as keys spread
29
- * uniformly across shards. Reserve it for high-volume limits where
30
- * contention bites; leave unset (one bucket) otherwise. Must be a positive
31
- * integer — `1` is equivalent to unset.
32
- */
33
- shards?: number;
34
22
  /**
35
23
  * Phase offset in epoch milliseconds for windowed algorithms — windows
36
24
  * align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
@@ -92,6 +80,30 @@ interface RateLimitStore {
92
80
  delete: (storageKey: string) => Promise<void> | void;
93
81
  get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
94
82
  set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
83
+ /**
84
+ * Optional conditional write: persist `value` only while the stored value is
85
+ * still `prior` (what `get` returned for this consume, `undefined` for no
86
+ * row), and resolve `false` when it no longer is. When present the limiter
87
+ * writes through it instead of `set`, and a `false` makes it read and decide
88
+ * again rather than admit off a stale read.
89
+ *
90
+ * A store whose `get` and write cannot be interleaved by another consume
91
+ * (memory, SQL: synchronous) needs none. One whose calls can be
92
+ * (`createDbStore`: every call is awaited I/O) must have it.
93
+ */
94
+ setIf?: (storageKey: string, value: RateLimitValue, prior: RateLimitValue | undefined) => Promise<boolean>;
95
+ /**
96
+ * Optional eviction hint, called by the limiter after each successful write.
97
+ * Examine up to `limit` entries from the store's oldest end and remove those
98
+ * whose `ts` is at or before `olderThanTs`, except entries with a negative
99
+ * `value`: a reserved debt is kept, whatever its age. `limit` caps entries
100
+ * examined, not entries removed. Omit it when the store's rows must persist.
101
+ *
102
+ * `owns` says whether a storage key belongs to the limiter asking for the
103
+ * sweep. A store shared with another limiter must leave the other limiter's
104
+ * keys alone: its horizon is not this one's. Absent means every key is owned.
105
+ */
106
+ sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => void;
95
107
  }
96
108
  /** Inputs to {@link evaluate}. */
97
109
  interface EvaluateOptions {
@@ -135,11 +147,15 @@ interface RateLimiterOptions<Names extends string> {
135
147
  denyList?: Iterable<string>;
136
148
  /**
137
149
  * Optional key normalizer applied to every incoming `args.key` (the
138
- * deny-list check, the storage key, and downstream shard selection all see
139
- * the normalized form). Use for case-folding, trimming, or canonicalizing
140
- * IPs/emails so equivalent inputs share a single bucket. The deny-list
150
+ * deny-list check and the storage key both see the normalized form). Use for case-folding, trimming, or canonicalizing
151
+ * emails so equivalent inputs share a single bucket. The deny-list
141
152
  * itself is consulted as-is; normalize the deny-list entries up front to
142
153
  * match.
154
+ *
155
+ * After it, a key that is an IPv6 address is always collapsed to its /64
156
+ * (`2001:db8:1:2::/64`) and an IPv4-mapped one to its IPv4 address, so a
157
+ * `ctx.ip`-keyed limit cannot be dodged by rotating addresses within one
158
+ * delegated prefix. A deny-list IPv6 entry therefore bans its whole /64.
143
159
  */
144
160
  normalize?: (key: string) => string;
145
161
  /** Clock injection for tests. Defaults to `Date.now`. */
@@ -158,7 +174,10 @@ declare class RateLimiter<Names extends string = string> {
158
174
  private readonly denyList;
159
175
  private readonly normalize;
160
176
  private readonly now;
177
+ private readonly retention;
161
178
  private readonly store;
179
+ /** Whether a stored key is one of this limiter's names; a sweep only evicts those. */
180
+ private readonly owns;
162
181
  constructor(options: RateLimiterOptions<Names>);
163
182
  /** Peek at whether a request would be permitted without consuming. */
164
183
  check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
@@ -166,9 +185,7 @@ declare class RateLimiter<Names extends string = string> {
166
185
  * Read the current config and the units admittable right now for a
167
186
  * `(name, key)` pair. The value is projected forward to the current clock
168
187
  * (token-bucket refill, fixed-window rollover, sliding-window decay), not
169
- * the last persisted figure. For a sharded limit it reads only the single
170
- * shard `limit()`/`run()` would route this key to — the sibling shards are
171
- * never touched by this key, so summing them would over-report.
188
+ * the last persisted figure.
172
189
  */
173
190
  getValue(name: Names, args?: {
174
191
  key?: string;
@@ -183,6 +200,17 @@ declare class RateLimiter<Names extends string = string> {
183
200
  reset(name: Names, args?: {
184
201
  key?: string;
185
202
  }): Promise<void>;
203
+ /**
204
+ * Read, evaluate and write one consume. A store that reports the write lost
205
+ * (`setIf` resolved `false`: another consume changed the value since this one
206
+ * read it) gets a fresh read and a fresh decision, so the admission always
207
+ * rests on the value the write replaced. Bounded: each lost round means some
208
+ * other consume was admitted (a refusal writes nothing), and past
209
+ * {@link MAX_DECIDE_ATTEMPTS} the call is refused with `retryAfter: 0` rather
210
+ * than admitted.
211
+ */
212
+ private decide;
213
+ private normalizeKey;
186
214
  private resolve;
187
215
  private run;
188
216
  }
@@ -202,7 +230,15 @@ interface RateLimitMiddlewareOptions<Context> {
202
230
  * — note that a failing limiter then permits every request through.
203
231
  */
204
232
  failOpen?: boolean;
205
- /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
233
+ /**
234
+ * Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
235
+ *
236
+ * A resolver that returns `undefined` is a config bug, not a global limit:
237
+ * the middleware throws `INTERNAL` rather than silently pooling every
238
+ * keyless caller (e.g. every anonymous user) into one shared bucket. Fold
239
+ * the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
240
+ * shared bucket is a visible choice.
241
+ */
206
242
  key?: (context: Context) => string | undefined;
207
243
  /** Override the error message thrown on rejection. */
208
244
  message?: string;
@@ -211,7 +247,7 @@ interface RateLimitMiddlewareOptions<Context> {
211
247
  * Procedure middleware that enforces a named rate limit before the handler
212
248
  * runs. Attach it with `.use()`. On rejection it throws a structural
213
249
  * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
214
- * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
250
+ * hits) carrying `data.retryAfterMs` — the runtime maps it to the
215
251
  * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
216
252
  *
217
253
  * **Failure policy:** if resolving or invoking the limiter throws for a genuine
@@ -230,6 +266,9 @@ declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: stri
230
266
  * In-memory store. State lives for the lifetime of the process (or, inside a
231
267
  * Durable Object, the instance) — adequate for single-DO limits but not shared
232
268
  * across instances. Use {@link createSqlStore} for durable per-DO state.
269
+ *
270
+ * A store may be shared by several limiters: each sweep evicts only the keys its
271
+ * limiter owns, by that limiter's horizon (see `RateLimitStore.sweep`).
233
272
  */
234
273
  declare const createMemoryStore: () => RateLimitStore;
235
274
  /**
@@ -250,34 +289,14 @@ interface SqlStoreOptions {
250
289
  * SQLite-backed store for durable, per-DO rate-limit state. Persists each
251
290
  * `(name, key)` pair as one row so limits survive hibernation and eviction.
252
291
  *
253
- * **Atomicity:** the store does not wrap individual operations in an explicit
254
- * SQL transaction. Inside a Durable Object the DO's input gate serializes
255
- * every RPC call against the storage, so the limiter's read-modify-write
256
- * sequence runs to completion without interleaving — this is the same
257
- * guarantee the surrounding `evaluate()` step depends on. **Outside a DO**
258
- * (e.g. driving `createSqlStore` from a long-lived `node:sqlite` connection in
259
- * tests or a custom host) the caller is responsible for serialization; the
260
- * SQL surface used here (`exec`) is not a substitute for transactional
261
- * isolation across concurrent invocations.
292
+ * **Atomicity:** `exec` is synchronous and the limiter does not yield between a
293
+ * synchronous store's read and its write, so one consume's read, decision and
294
+ * write run as a single JavaScript step. No other call on the same isolate can
295
+ * interleave, which is what makes concurrent consumes admit exactly the
296
+ * configured rate. Two isolates sharing one SQLite file (not a Durable Object
297
+ * case) are not covered; serialize those yourself.
262
298
  */
263
299
  declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
264
- /**
265
- * The slice of an index-range builder the store uses. Mirrors `@lunora/server`'s
266
- * `IndexRangeBuilder` field-for-field so the real `ctx.db` query builder is
267
- * assignable; only `eq` is exercised.
268
- */
269
- interface RateLimitDatabaseIndexRange {
270
- eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
271
- gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
272
- gte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
273
- lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
274
- lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
275
- }
276
- /** The slice of a `ctx.db` table query the store relies on. */
277
- interface RateLimitDatabaseQuery {
278
- first: () => Promise<Record<string, unknown> | null>;
279
- withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
280
- }
281
300
  /**
282
301
  * The READ slice — everything the store needs to answer `RateLimiter.getValue`
283
302
  * and `check`, which the docs describe as projecting the stored value forward to
@@ -288,9 +307,16 @@ interface RateLimitDatabaseQuery {
288
307
  * at all — every remaining-quota display had to cast, and a cast that appears
289
308
  * often enough stops carrying information. The distinction was already in the
290
309
  * methods: `getValue`/`check` read, `limit`/`reset` write.
310
+ *
311
+ * `findFirst` rather than `query(table).withIndex(...)`: a `.global()` table's
312
+ * reader has no `withIndex`, so the indexed builder failed every read there.
313
+ * `findFirst` routes to whichever backend owns the table, and an equality on the
314
+ * key column still uses its index.
291
315
  */
292
316
  interface RateLimitDatabaseReader {
293
- query: (table: string) => RateLimitDatabaseQuery;
317
+ findFirst: (table: string, args?: {
318
+ where?: Record<string, unknown>;
319
+ }) => Promise<Record<string, unknown> | null>;
294
320
  }
295
321
  /**
296
322
  * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
@@ -300,14 +326,20 @@ interface RateLimitDatabaseReader {
300
326
  */
301
327
  interface RateLimitDatabase extends RateLimitDatabaseReader {
302
328
  delete: <T extends string>(id: Id<T>) => Promise<void>;
303
- insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
304
- patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
329
+ insert: <T extends string>(table: T, document: Record<string, unknown>, options?: {
330
+ clientId?: string;
331
+ }) => Promise<Id<T>>;
332
+ patchIf: <T extends string>(table: T, id: Id<T>, args: {
333
+ patch: Record<string, unknown>;
334
+ where: Record<string, unknown>;
335
+ }) => Promise<{
336
+ applied: boolean;
337
+ doc: Record<string, unknown> | null;
338
+ }>;
305
339
  }
306
- /** The table/column/index knobs shared by the read-only and read-write stores. */
340
+ /** The table/column knobs shared by the read-only and read-write stores. */
307
341
  interface DatabaseStoreLocation {
308
- /** Index that resolves a row by its key column. Defaults to `by_key`. */
309
- index?: string;
310
- /** Column storing the opaque key. Defaults to `key`. */
342
+ /** Column storing the opaque key. Defaults to `key`. Index it: every consume reads by it. */
311
343
  keyField?: string;
312
344
  /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
313
345
  table?: string;
@@ -321,9 +353,10 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
321
353
  db: RateLimitDatabaseReader;
322
354
  }
323
355
  /**
324
- * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
325
- * inside a procedure (the procedure context exposes no raw SQL). Declare a
326
- * table with the key column and its index, e.g.
356
+ * Store backed by a Lunora table through `ctx.db`, for durable limits inside a
357
+ * procedure (the procedure context exposes no raw SQL). Works on a shard-local
358
+ * table and on a `.global()` one. Declare a table with the key column and its
359
+ * index, e.g.
327
360
  *
328
361
  * ```ts
329
362
  * rateLimits: defineTable({
@@ -334,8 +367,22 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
334
367
  * }).index("by_key", ["key"])
335
368
  * ```
336
369
  *
337
- * Each operation is a read-then-write; inside a mutation/action that pair runs
338
- * under the DO's input gate, so it is atomic against concurrent calls.
370
+ * **Atomic against concurrent consumes.** `setIf` writes only if the row still
371
+ * holds the value `get` returned: an existing row through `ctx.db.patchIf` with
372
+ * that value as its `where`, a new row through an `insert` under an id derived
373
+ * from the key, which a concurrent creator's insert collides with. A consume
374
+ * that loses either race gets `false` back and the limiter reads and decides
375
+ * again, so a limit of 1 admits one caller however many arrive together — on a
376
+ * `.global()` table too, where D1 calls do not hold the Durable Object's input
377
+ * gate.
378
+ *
379
+ * **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
380
+ * its storage transaction, so a handler that throws after `limit()` rolls the
381
+ * consumed unit back with everything else — inside a mutation this store counts
382
+ * successful calls, not attempts. To charge every attempt (a login limiter),
383
+ * consume from an action, where each write commits on its own, or return a
384
+ * failure value from the mutation instead of throwing. A `.global()` table's
385
+ * row lives in D1, outside the DO transaction, so there the unit stays spent.
339
386
  */
340
387
  declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
341
388
  /**
@@ -364,12 +411,17 @@ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOption
364
411
  * on the DO the procedure runs on). Every query/mutation/action ctx exposes a
365
412
  * compatible `db`, so it slots straight into a `.use(...)` chain.
366
413
  *
367
- * Pass `options.store` to point at a non-default backing table/index/key column
368
- * (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
414
+ * Pass `options.store` to point at a non-default backing table/key column
415
+ * (defaults: table `rateLimits`, key column `key`); the rest of
369
416
  * `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
370
417
  * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
371
418
  * autocompletes to its declared limit names.
372
419
  *
420
+ * On a mutation the consumed unit commits with the handler: a handler that
421
+ * throws rolls it back, so a failed call costs nothing. Attach it to an action
422
+ * (whose writes commit independently) when failed attempts must count — see
423
+ * {@link createDatabaseStore}.
424
+ *
373
425
  * Re-exported as `dbRateLimit` from the package root.
374
426
  *
375
427
  * ```ts
@@ -420,7 +472,7 @@ interface RatelimitApiContext<Context> {
420
472
  *
421
473
  * ```ts
422
474
  * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
423
- * const c = initLunora.dataModel&lt;DataModel>().create();
475
+ * const c = initLunora.dataModel<DataModel>().create();
424
476
  * export const send = c.mutation
425
477
  * .use(ratelimitPlugin(limiter).middleware!)
426
478
  * .mutation(async ({ ctx, args }) => {
@@ -439,5 +491,50 @@ interface RatelimitApiContext<Context> {
439
491
  * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
440
492
  */
441
493
  declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
494
+ /** A budget bound to one named limit — check before the call, record after it. */
495
+ interface TokenBudget {
496
+ /**
497
+ * Peek at the budget before spending. `ok: false` means it is exhausted:
498
+ * refuse the call, and `retryAfter` says when it refills. Consumes nothing.
499
+ */
500
+ check: (key: string) => Promise<RateLimitStatus>;
501
+ /**
502
+ * Charge the tokens a call actually used. Always call it, including when the
503
+ * call THREW — a failed generation that consumed input tokens still has to be
504
+ * paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
505
+ * nothing.
506
+ *
507
+ * The charge is a reservation, so it may take the bucket negative: the tokens
508
+ * are already spent, and refusing to record them would let a single oversized
509
+ * call escape the budget entirely.
510
+ */
511
+ record: (key: string, tokens: number) => Promise<RateLimitStatus>;
512
+ }
513
+ /**
514
+ * Bind a {@link TokenBudget} to one of a limiter's named limits.
515
+ *
516
+ * ```ts
517
+ * const budget = tokenBudget(limiter, "tokens");
518
+ * const allowed = await budget.check(userId);
519
+ *
520
+ * if (!allowed.ok) {
521
+ * throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
522
+ * }
523
+ *
524
+ * try {
525
+ * const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
526
+ *
527
+ * await budget.record(userId, usage?.totalTokens ?? 0);
528
+ *
529
+ * return text;
530
+ * } catch (error) {
531
+ * // The prompt was still sent — charge what is known, then rethrow.
532
+ * await budget.record(userId, estimatedInputTokens);
533
+ *
534
+ * throw error;
535
+ * }
536
+ * ```
537
+ */
538
+ declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
442
539
  declare const VERSION = "0.0.0";
443
- export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseIndexRange as RateLimitDbIndexRange, type RateLimitDatabaseQuery as RateLimitDbQuery, type RateLimitDatabaseReader as RateLimitDbReader, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type ReadOnlyDatabaseStoreOptions as ReadOnlyDbStoreOptions, type SqlLike, type SqlStoreOptions, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createReadOnlyDatabaseStore as createReadOnlyDbStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin };
540
+ export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseReader as RateLimitDbReader, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type ReadOnlyDatabaseStoreOptions as ReadOnlyDbStoreOptions, type SqlLike, type SqlStoreOptions, type TokenBudget, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createReadOnlyDatabaseStore as createReadOnlyDbStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin, tokenBudget };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-DITZ7jdT.mjs";import{default as i}from"./packem_shared/dbRateLimit-CrjZatIs.mjs";import{default as l}from"./packem_shared/RateLimitError-xJ2Iv-i7.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-DLcwK-84.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-D1QHZF9b.mjs";import{RateLimiter as b}from"./packem_shared/RateLimiter-uPXzD0St.mjs";import{createDbStore as u,createMemoryStore as L,createReadOnlyDbStore as n,createSqlStore as s}from"./packem_shared/createDbStore-CwJBMxDQ.mjs";const e="0.0.0";export{l as RateLimitError,b as RateLimiter,e as VERSION,o as availableAt,u as createDbStore,L as createMemoryStore,n as createReadOnlyDbStore,s as createSqlStore,i as dbRateLimit,a as evaluate,x as rateLimit,c as ratelimitPlugin};
1
+ import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-BhJ1lMBZ.mjs";import{default as i}from"./packem_shared/dbRateLimit-BgxKsJmX.mjs";import{default as l}from"./packem_shared/RateLimitError-9Y5OLxpl.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-CWZur6XE.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-CFnDs9rj.mjs";import{RateLimiter as u}from"./packem_shared/RateLimiter-CCSZUABq.mjs";import{createDbStore as b,createMemoryStore as n,createReadOnlyDbStore as L,createSqlStore as s}from"./packem_shared/createDbStore-Dma2vpJG.mjs";import{tokenBudget as v}from"./packem_shared/tokenBudget-CvkodZ0R.mjs";const e="0.0.0";export{l as RateLimitError,u as RateLimiter,e as VERSION,o as availableAt,b as createDbStore,n as createMemoryStore,L as createReadOnlyDbStore,s as createSqlStore,i as dbRateLimit,a as evaluate,x as rateLimit,c as ratelimitPlugin,v as tokenBudget};
@@ -0,0 +1 @@
1
+ import{LunoraError as n}from"@lunora/errors";import{STATUS_BY_REASON as a}from"./rateLimit-CWZur6XE.mjs";const s=r=>r.reason==="deny"?"request denied (deny list)":Number.isFinite(r.retryAfter)?`rate limit exceeded; retry after ${String(Math.ceil(r.retryAfter))}ms`:"rate limit exceeded";class c extends n{reason;retryAfter;constructor(e,t){const{code:i,status:o}=a[e.reason??"rate"];super(i,t??s(e),{name:"RateLimitError",status:o}),this.reason=e.reason,this.retryAfter=e.retryAfter}}export{c as default};
@@ -0,0 +1 @@
1
+ import{LunoraError as h}from"@lunora/errors";import{retentionMs as p,availableAt as y,evaluate as v}from"./availableAt-BhJ1lMBZ.mjs";import f from"./RateLimitError-9Y5OLxpl.mjs";import{createMemoryStore as I}from"./createDbStore-Dma2vpJG.mjs";const b=/^[\d.:a-f]+$/iu,d=r=>{const t=r.indexOf("%"),e=t===-1?r:r.slice(0,t);if(!e.includes(":")||!b.test(e))return r;let s;try{s=new URL(`http://[${e}]/`).hostname.slice(1,-1)}catch{return r}const[i="",n]=s.split("::"),a=i===""?[]:i.split(":"),l=n===void 0||n===""?[]:n.split(":"),c=[...a,...Array.from({length:8-a.length-l.length},()=>"0"),...l].map(o=>Number.parseInt(o,16));if(c.slice(0,5).every(o=>o===0)&&c[5]===65535){const[o=0,m=0]=c.slice(6);return[o>>8,o&255,m>>8,m&255].join(".")}return`${c.slice(0,4).map(o=>o.toString(16)).join(":")}::/64`},u=(r,t)=>t===void 0?encodeURIComponent(r):`${encodeURIComponent(r)}:${encodeURIComponent(t)}`,N=16,w=32,g=r=>typeof r?.then=="function",L=r=>{let t=Number.NEGATIVE_INFINITY;return()=>(t=Math.max(t,r()),t)},R=r=>r?t=>d(r(t)):d,T="@lunora/ratelimit: new RateLimiter() was built with no explicit `store`, so it falls back to `createMemoryStore()` — an in-process Map. That Map is durable and correctly shared only for calls that land on the same Durable Object instance; it is NOT shared across `.shardBy(...)` shards or `.global()` replicas, and it resets when the DO instance is evicted/restarted. For a limit that must hold across any of those, pass a durable `store` (`createDbStore` or `createSqlStore`) instead. Pass `store: createMemoryStore()` explicitly once you've confirmed the in-memory default is correct here, to silence this warning.";class M{config;denyList;normalize;now;retention;store;owns;constructor(t){this.config=structuredClone(t.config);const e=new Set(Object.keys(this.config).map(s=>encodeURIComponent(s)));this.owns=s=>e.has(s.split(":")[0]??""),this.normalize=R(t.normalize),this.denyList=new Set([...t.denyList??[]].flatMap(s=>[s,this.normalize(s)])),this.now=L(t.now??Date.now),t.store===void 0&&console.warn(T),this.store=t.store??I(),this.retention=p(Object.values(this.config));for(const[s,i]of Object.entries(this.config)){if(Object.hasOwn(i,"shards"))throw new h("INTERNAL",`rate limit "${s}": \`shards\` is no longer supported — remove it; the limit now enforces its full \`rate\` (lower \`rate\` if you raised it to offset the split)`);if(!Number.isFinite(i.period)||i.period<=0)throw new h("INTERNAL",`rate limit "${s}": period must be a positive number`);if(!Number.isFinite(i.rate)||i.rate<=0)throw new h("INTERNAL",`rate limit "${s}": rate must be a positive number`);if(i.capacity!==void 0&&(!Number.isFinite(i.capacity)||i.capacity<0))throw new h("INTERNAL",`rate limit "${s}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const s=this.resolve(t),i=this.now(),n=u(t,this.normalizeKey(e.key)),a=y(s,await this.store.get(n),i);return{config:s,ts:a.ts,value:a.value}}async limit(t,e={}){return this.run(t,e,!0)}async reset(t,e={}){this.resolve(t),await Promise.resolve(this.store.delete(u(t,this.normalizeKey(e.key))))}async decide(t,e,s){for(let i=0;i<N;i+=1){const n=this.store.get(e),a=g(n)?await n:n,l=this.now(),{status:c,value:o}=v(t,a,{...s,now:l});if(o===void 0)return c;if(this.store.setIf===void 0)return await this.store.set(e,o),this.store.sweep?.(l-this.retention,w,this.owns),c;if(await this.store.setIf(e,o,a))return this.store.sweep?.(l-this.retention,w,this.owns),c}return{ok:!1,reason:"rate",retryAfter:0}}normalizeKey(t){return t===void 0?void 0:this.normalize(t)}resolve(t){const e=this.config[t];if(!e)throw new h("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,s){const i=this.resolve(t),n=this.normalizeKey(e.key);if(n!==void 0&&(this.denyList.has(n)||this.denyList.has(e.key))){const o={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new f(o);return o}const a=e.count??1;if(!Number.isInteger(a)||a<=0)throw new h("INTERNAL",`rate limit "${t}": count must be a positive integer`);const l=u(t,n),c=await this.decide(i,l,{consume:s,count:a,reserve:e.reserve??!1});if(!c.ok&&e.throws)throw new f(c);return c}}export{M as RateLimiter};
@@ -0,0 +1 @@
1
+ import{LunoraError as b}from"@lunora/errors";const o=t=>t.capacity??t.rate,S=t=>t.kind==="sliding window"?t.rate:o(t),i=(t,a)=>{throw new b("INTERNAL",`@lunora/ratelimit: requested count ${String(t)} exceeds the limiter capacity ${String(a)}`)},M=(t,a,e)=>{const u=o(t),r=t.rate/t.period,s=a??{ts:e,value:u},c=Math.max(0,e-s.ts);return{available:Math.min(u,s.value+c*r),capacity:u,ratePerMs:r}},w=(t,a,e)=>{const u=t.start??0,r=u+Math.floor((e-u)/t.period)*t.period;if(!a)return{ts:r,value:o(t)};if(a.ts<r){let s=0,c=1;return(a.value<0||t.capacity!==void 0)&&(s=a.value,c=Math.max(1,Math.ceil((r-a.ts)/t.period))),{ts:r,value:Math.min(o(t),s+c*t.rate)}}return{ts:a.ts,value:a.value}},x=(t,a,e)=>{const u=t.start??0,r=u+Math.floor((e-u)/t.period)*t.period,s=e-r,c=(t.period-s)/t.period;let n=0,l=0;return a?.ts===r?(n=a.prev??0,l=a.value):a?.ts===r-t.period&&(n=a.value),{currentCount:l,elapsed:s,previousCount:n,weight:c,windowStart:r}},W=(t,a,e)=>{const{available:u,capacity:r,ratePerMs:s}=M(t,a,e.now);if(u>=e.count){const l={ts:e.now,value:u-e.count};return{status:{ok:!0,retryAfter:0},value:e.consume?l:void 0}}const c=e.count-u,n=Math.ceil(c/s);return e.consume&&e.reserve&&e.count<=r?{status:{ok:!0,retryAfter:n},value:{ts:e.now,value:u-e.count}}:(e.count>r&&i(e.count,r),{status:{ok:!1,reason:"rate",retryAfter:n},value:void 0})},y=(t,a,e,u,r,s)=>{const c=d=>w(t,a,e+d*t.period).value>=r;let l=u<0||t.capacity!==void 0?Math.max(1,Math.ceil((r-u)/t.rate)):1;for(let d=0;d<4&&l>1&&c(l-1);d+=1)l-=1;for(let d=0;d<4&&!c(l);d+=1)l+=1;return e+l*t.period-s},N=(t,a,e)=>{const u=o(t),r=w(t,a,e.now);if(r.value>=e.count){const s={ts:r.ts,value:r.value-e.count};return{status:{ok:!0,retryAfter:0},value:e.consume?s:void 0}}if(e.consume&&e.reserve&&e.count<=u){const s=r.value-e.count;return{status:{ok:!0,retryAfter:y(t,{ts:r.ts,value:s},r.ts,s,0,e.now)},value:{ts:r.ts,value:s}}}return e.count>u&&i(e.count,u),{status:{ok:!1,reason:"rate",retryAfter:y(t,a,r.ts,r.value,e.count,e.now)},value:void 0}},j=(t,a,e)=>{const u=t.rate,{period:r}=t,{currentCount:s,elapsed:c,previousCount:n,weight:l,windowStart:d}=x(t,a,e.now),m=n*l+s+e.count<=u,h=v=>{const k=u-v-e.count;if(n>0&&k>=0)return Math.ceil(r-c-k*r/n);const A=u-e.count,C=v>0?Math.max(0,r-A*r/v):0;return Math.ceil(r-c+C)};if(m||e.consume&&e.reserve&&e.count<=u){const v={prev:n,ts:d,value:s+e.count};return{status:{ok:!0,retryAfter:m?0:h(v.value)},value:e.consume?v:void 0}}return e.count>u&&i(e.count,u),{status:{ok:!1,reason:"rate",retryAfter:h(s)},value:void 0}},B=(t,a,e)=>{if(t.kind==="token bucket")return{ts:e,value:M(t,a,e).available};if(t.kind==="sliding window"){const{currentCount:u,previousCount:r,weight:s,windowStart:c}=x(t,a,e);return{ts:c,value:Math.max(0,t.rate-(r*s+u))}}return w(t,a,e)},L=t=>Math.max(0,...t.map(a=>a.period*(2+S(a)/a.rate))),P=(t,a,e)=>t.kind==="token bucket"?W(t,a,e):t.kind==="sliding window"?j(t,a,e):N(t,a,e);export{B as availableAt,S as enforcedCapacity,P as evaluate,L as retentionMs};
@@ -0,0 +1 @@
1
+ import{LunoraError as E}from"@lunora/errors";const m=()=>{const r=new Map;return{delete:e=>{r.delete(e)},get:e=>r.get(e),set:(e,c)=>{r.delete(e),r.set(e,c)},sweep:(e,c,s)=>{let n=0;for(const[l,o]of r){if(n>=c)break;n+=1,r.delete(l),(o.value<0||o.ts>e||s?.(l)===!1)&&r.set(l,o)}}}},u=(r,e,...c)=>r.exec.call(r,e,...c).toArray(),L=r=>{const{sql:e}=r,c=r.table??"_lunora_rate_limits";return u(e,`CREATE TABLE IF NOT EXISTS "${c}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),{delete:s=>{u(e,`DELETE FROM "${c}" WHERE k = ?`,s)},get:s=>{const l=u(e,`SELECT value, ts, prev FROM "${c}" WHERE k = ?`,s)[0];if(!l)return;const o={ts:l.ts,value:l.value};return l.prev!==null&&(o.prev=l.prev),o},set:(s,n)=>{u(e,`INSERT INTO "${c}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,s,n.value,n.ts,n.prev??null)}}},b=r=>r.toString(16).padStart(2,"0"),S=async(r,e)=>{const s=[...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(`${r}\0${e}`))).subarray(0,16)];s[6]=128+s[6]%16,s[8]=128+s[8]%64;const n=s.map(l=>b(l)).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`},T=r=>r?.code==="CONFLICT",h=r=>{const{db:e}=r,c=r.table??"rateLimits",s=r.keyField??"key",n=new Map,l=async t=>{const a=await e.findFirst(c,{where:{[s]:t}});return n.set(t,a?a._id:void 0),a},o=async t=>(n.has(t)||await l(t),n.get(t)),v=(t,a)=>{const i={[s]:t,ts:a.ts,value:a.value};return a.prev!==void 0&&(i.prev=a.prev),i},f=async(t,a)=>{try{return n.set(t,await e.insert(c,a,{clientId:await S(c,t)})),!0}catch(i){if(T(i))return n.delete(t),!1;throw i}};return{delete:async t=>{const a=await o(t);a!==void 0&&await e.delete(a),n.delete(t)},get:async t=>{const a=await l(t);if(!a)return;const i={ts:a.ts,value:a.value};return a.prev!==null&&a.prev!==void 0&&(i.prev=a.prev),i},set:async(t,a)=>{const i=v(t,a);let d=await o(t);if(d===void 0){if(await f(t,i))return;await l(t),d=n.get(t)}d!==void 0&&await e.patchIf(c,d,{patch:i,where:{}})},setIf:async(t,a,i)=>{const d=v(t,a);if(i===void 0)return f(t,d);const w=await o(t);if(w===void 0)return!1;const{applied:p}=await e.patchIf(c,w,{patch:d,where:{prev:i.prev??null,ts:i.ts,value:i.value}});return p||n.delete(t),p}}},R=r=>{const e=s=>{throw new E("INTERNAL",`@lunora/ratelimit: \`${s}\` needs a writable \`ctx.db\`, but this store was created with \`createReadOnlyDbStore\` (a query context). Use \`createDbStore\` from a mutation or action; a query can only call \`getValue\`/\`check\`.`)},c=h({...r,db:{delete:()=>e("delete"),findFirst:r.db.findFirst.bind(r.db),insert:()=>e("insert"),patchIf:()=>e("patchIf")}});return{delete:()=>e("reset"),get:c.get,set:()=>e("limit"),setIf:()=>e("limit")}};export{h as createDbStore,m as createMemoryStore,R as createReadOnlyDbStore,L as createSqlStore};
@@ -0,0 +1 @@
1
+ import{rateLimit as o}from"./rateLimit-CWZur6XE.mjs";import{RateLimiter as m}from"./RateLimiter-CCSZUABq.mjs";import{createDbStore as i}from"./createDbStore-Dma2vpJG.mjs";const f=(e,r,t={})=>o(a=>new m({config:e,store:i({db:a.db,...t.store})}),r,t);export{f as default};
@@ -0,0 +1 @@
1
+ import{isLunoraError as c,isInternalCode as l,LunoraError as n}from"@lunora/errors";import{tagPerDispatchMiddleware as f}from"@lunora/server";const v={deny:{code:"FORBIDDEN",status:403},rate:{code:"TOO_MANY_REQUESTS",status:429}},y=(r,t,e)=>t==="deny"?`request denied for "${r}"`:e===void 0?`rate limit "${r}" exceeded`:`rate limit "${r}" exceeded; retry after ${String(e)}ms`,w=(r,t,e)=>{if(!e)return;const a=e(t);if(a===void 0)throw new n("INTERNAL",`@lunora/ratelimit: rateLimit("${r}") key resolver returned undefined; return a fallback such as "anonymous" instead`);return a},h=(r,t,e={})=>f(async({ctx:a,next:d})=>{let i;try{i=await(typeof r=="function"?await r(a):r).limit(t,{count:e.count,key:w(t,a,e.key)})}catch(o){if(c(o)&&l(o.code))throw o;if(console.error(`@lunora/ratelimit: rateLimit("${t}") threw; ${e.failOpen?"failing open":"failing closed"}`,o),e.failOpen)return d();throw new n("SERVICE_UNAVAILABLE",`rate limiter unavailable for "${t}"`,{cause:o,status:503})}if(!i.ok){const o=i.reason??"rate",u=v[o],s=Number.isFinite(i.retryAfter)?Math.ceil(i.retryAfter):void 0;throw new n(u.code,e.message??y(t,o,s),{status:u.status,data:s===void 0?void 0:{retryAfterMs:s}})}return d()});export{v as STATUS_BY_REASON,h as rateLimit};
@@ -0,0 +1 @@
1
+ const r=t=>({key:"ratelimit",middleware:async({ctx:e,next:i})=>{const n=typeof t=="function"?await t(e):t,a=e.api??{};return i({ctx:{api:{...a,ratelimit:n}}})}});export{r as ratelimitPlugin};
@@ -0,0 +1 @@
1
+ import{enforcedCapacity as n}from"./availableAt-BhJ1lMBZ.mjs";const h=(t,r)=>({check:async c=>t.check(r,{key:c}),record:async(c,a)=>{if(!Number.isFinite(a)||a<=0)return t.check(r,{key:c});const{config:e}=await t.getValue(r,{key:c}),o=Math.max(1,Math.floor(n(e)));return t.limit(r,{count:Math.min(Math.ceil(a),o),key:c,reserve:!0})}});export{h as tokenBudget};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lunora/ratelimit",
3
- "version": "1.0.0-alpha.14",
4
- "description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, sharding, pluggable stores, and procedure middleware",
3
+ "version": "1.0.0-alpha.140",
4
+ "description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, pluggable stores, and procedure middleware",
5
5
  "keywords": [
6
6
  "cloudflare",
7
7
  "durable-objects",
@@ -46,7 +46,11 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.10"
49
+ "@lunora/errors": ">=1.0.0-alpha.51 <2.0.0-0"
50
+ },
51
+ "peerDependencies": {
52
+ "@lunora/server": ">=1.0.0-alpha.24 <2.0.0-0",
53
+ "@lunora/values": ">=1.0.0-alpha.7 <2.0.0-0"
50
54
  },
51
55
  "engines": {
52
56
  "node": "^22.15.0 || >=24.11.0"
@@ -1 +0,0 @@
1
- import{LunoraError as i}from"@lunora/errors";import{STATUS_BY_REASON as o}from"./rateLimit-DLcwK-84.mjs";const n=e=>e.reason==="deny"?"request denied (deny list)":Number.isFinite(e.retryAfter)?`rate limit exceeded; retry after ${String(Math.ceil(e.retryAfter))}ms`:"rate limit exceeded";class c extends i{reason;retryAfter;constructor(r,t){const{code:s,status:a}=o[r.reason??"rate"];super(s,t??n(r),{name:"RateLimitError",status:a}),this.reason=r.reason,this.retryAfter=r.retryAfter}}export{c as default};
@@ -1 +0,0 @@
1
- import{LunoraError as n}from"@lunora/errors";import{availableAt as p,evaluate as N}from"./availableAt-DITZ7jdT.mjs";import d from"./RateLimitError-xJ2Iv-i7.mjs";import{createMemoryStore as g}from"./createDbStore-CwJBMxDQ.mjs";const v=(r,t)=>t===void 0?encodeURIComponent(r):`${encodeURIComponent(r)}:${encodeURIComponent(t)}`,b=(r,t)=>{let e=0;for(let i=0;i<r.length;i+=1)e=e*31+r.charCodeAt(i)|0;return Math.abs(e)%t},w=(r,t)=>t>1?{...r,capacity:(r.capacity??r.rate)/t,rate:r.rate/t}:r,I=(r,t,e)=>{const i=v(r,t);return e>1?Array.from({length:e},(o,s)=>`${i}#${String(s)}`):[i]},f=(r,t,e)=>{const i=v(r,t);return e>1?`${i}#${String(b(i,e))}`:i};class R{config;denyList;normalize;now;store;constructor(t){this.config=t.config,this.denyList=new Set(t.denyList),this.normalize=t.normalize??(e=>e),this.now=t.now??Date.now,this.store=t.store??g();for(const[e,i]of Object.entries(this.config)){if(i.shards!==void 0&&(!Number.isInteger(i.shards)||i.shards<1))throw new n("INTERNAL",`rate limit "${e}": shards must be a positive integer`);if(!Number.isFinite(i.period)||i.period<=0)throw new n("INTERNAL",`rate limit "${e}": period must be a positive number`);if(!Number.isFinite(i.rate)||i.rate<=0)throw new n("INTERNAL",`rate limit "${e}": rate must be a positive number`);if(i.capacity!==void 0&&(!Number.isFinite(i.capacity)||i.capacity<0))throw new n("INTERNAL",`rate limit "${e}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const i=this.resolve(t),o=i.shards??1,s=this.now(),a=e.key===void 0?void 0:this.normalize(e.key),m=f(t,a,o),c=p(w(i,o),await this.store.get(m),s);return{config:i,ts:c.ts,value:c.value}}async limit(t,e={}){return this.run(t,e,!0)}async reset(t,e={}){const i=this.resolve(t).shards??1,o=e.key===void 0?void 0:this.normalize(e.key);await Promise.all(I(t,o,i).map(s=>Promise.resolve(this.store.delete(s))))}resolve(t){const e=this.config[t];if(!e)throw new n("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,i){const o=this.resolve(t),s=e.key===void 0?void 0:this.normalize(e.key);if(s!==void 0&&(this.denyList.has(s)||this.denyList.has(e.key))){const l={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new d(l);return l}const a=e.count??1;if(!Number.isInteger(a)||a<=0)throw new n("INTERNAL",`rate limit "${t}": count must be a positive integer`);const m=o.shards??1,c=f(t,s,m),y=await this.store.get(c),{status:h,value:u}=N(w(o,m),y,{consume:i,count:a,now:this.now(),reserve:e.reserve??!1});if(u!==void 0&&await this.store.set(c,u),!h.ok&&e.throws)throw new d(h);return h}}export{R as RateLimiter};
@@ -1 +0,0 @@
1
- import{LunoraError as l}from"@lunora/errors";const v=r=>r.capacity??r.rate,p=(r,e,t)=>{const n=v(r),u=r.rate/r.period,o=e??{ts:t,value:n},a=Math.max(0,t-o.ts);return{available:Math.min(n,o.value+a*u),capacity:n,ratePerMs:u}},w=(r,e,t)=>{const n=r.start??0,u=n+Math.floor((t-n)/r.period)*r.period;if(!e||e.ts<u){let o=0;return e&&(e.value<0||r.capacity!==void 0)&&(o=e.value),{ts:u,value:Math.min(v(r),o+r.rate)}}return{ts:e.ts,value:e.value}},h=(r,e,t)=>{const n=r.start??0,u=n+Math.floor((t-n)/r.period)*r.period,o=t-u,a=(r.period-o)/r.period;let i=0,s=0;return e?.ts===u?(i=e.prev??0,s=e.value):e?.ts===u-r.period&&(i=e.value),{currentCount:s,elapsed:o,previousCount:i,weight:a,windowStart:u}},A=(r,e,t)=>{const{available:n,capacity:u,ratePerMs:o}=p(r,e,t.now);if(n>=t.count){const s={ts:t.now,value:n-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?s:void 0}}const a=t.count-n,i=Math.ceil(a/o);if(t.consume&&t.reserve&&t.count<=u)return{status:{ok:!0,retryAfter:i},value:{ts:t.now,value:n-t.count}};if(t.count>u)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(u)}`);return{status:{ok:!1,reason:"rate",retryAfter:i},value:void 0}},M=(r,e,t)=>{const n=v(r),u=w(r,e,t.now);if(u.value>=t.count){const a={ts:u.ts,value:u.value-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?a:void 0}}const o=u.ts+r.period-t.now;if(t.consume&&t.reserve&&t.count<=n)return{status:{ok:!0,retryAfter:o},value:{ts:u.ts,value:u.value-t.count}};if(t.count>n)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(n)}`);return{status:{ok:!1,reason:"rate",retryAfter:o},value:void 0}},g=(r,e,t)=>{const n=r.rate,{period:u}=r,{currentCount:o,elapsed:a,previousCount:i,weight:s,windowStart:m}=h(r,e,t.now),d=i*s+o+t.count<=n,f=()=>{const c=n-o-t.count;if(i>0&&c>=0)return Math.ceil(u-a-c*u/i);const k=n-t.count,y=o>0?Math.max(0,u-k*u/o):0;return Math.ceil(u-a+y)};if(d||t.consume&&t.reserve&&t.count<=n){const c={prev:i,ts:m,value:o+t.count};return{status:{ok:!0,retryAfter:d?0:f()},value:t.consume?c:void 0}}if(t.count>n)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(n)}`);return{status:{ok:!1,reason:"rate",retryAfter:f()},value:void 0}},b=(r,e,t)=>{if(r.kind==="token bucket")return{ts:t,value:p(r,e,t).available};if(r.kind==="sliding window"){const{currentCount:n,previousCount:u,weight:o,windowStart:a}=h(r,e,t);return{ts:a,value:Math.max(0,r.rate-(u*o+n))}}return w(r,e,t)},x=(r,e,t)=>r.kind==="token bucket"?A(r,e,t):r.kind==="sliding window"?g(r,e,t):M(r,e,t);export{b as availableAt,x as evaluate};
@@ -1 +0,0 @@
1
- const p=()=>{const t=new Map;return{delete:e=>{t.delete(e)},get:e=>t.get(e),set:(e,s)=>{t.set(e,s)}}},u=(t,e,...s)=>t.exec.call(t,e,...s).toArray(),y=t=>{const{sql:e}=t,s=t.table??"_lunora_rate_limits";return u(e,`CREATE TABLE IF NOT EXISTS "${s}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),{delete:n=>{u(e,`DELETE FROM "${s}" WHERE k = ?`,n)},get:n=>{const l=u(e,`SELECT value, ts, prev FROM "${s}" WHERE k = ?`,n)[0];if(!l)return;const c={ts:l.ts,value:l.value};return l.prev!==null&&(c.prev=l.prev),c},set:(n,l)=>{u(e,`INSERT INTO "${s}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,n,l.value,l.ts,l.prev??null)}}},E=t=>{const{db:e}=t,s=t.table??"rateLimits",n=t.index??"by_key",l=t.keyField??"key",c=new Map,o=async a=>{const r=await e.query(s).withIndex(n,i=>i.eq(l,a)).first();return c.set(a,r?r._id:void 0),r},v=async a=>(c.has(a)||await o(a),c.get(a));return{delete:async a=>{const r=await v(a);r!==void 0&&await e.delete(r),c.delete(a)},get:async a=>{const r=await o(a);if(!r)return;const i={ts:r.ts,value:r.value};return r.prev!==null&&r.prev!==void 0&&(i.prev=r.prev),i},set:async(a,r)=>{const i=await v(a),d={[l]:a,ts:r.ts,value:r.value};r.prev!==void 0&&(d.prev=r.prev),i===void 0?c.set(a,await e.insert(s,d)):await e.patch(i,d)}}},b=t=>{const e=n=>{throw new Error(`@lunora/ratelimit: \`${n}\` needs a writable \`ctx.db\`, but this store was created with \`createReadOnlyDbStore\` (a query context). Use \`createDbStore\` from a mutation or action; a query can only call \`getValue\`/\`check\`.`)},s=E({...t,db:{delete:()=>e("delete"),insert:()=>e("insert"),patch:()=>e("patch"),query:t.db.query.bind(t.db)}});return{delete:()=>e("reset"),get:s.get,set:()=>e("limit")}};export{E as createDbStore,p as createMemoryStore,b as createReadOnlyDbStore,y as createSqlStore};
@@ -1 +0,0 @@
1
- import{rateLimit as a}from"./rateLimit-DLcwK-84.mjs";import{RateLimiter as i}from"./RateLimiter-uPXzD0St.mjs";import{createDbStore as m}from"./createDbStore-CwJBMxDQ.mjs";const p=(t,o,r={})=>a(e=>new i({config:t,store:m({db:e.db,...r.store})}),o,r);export{p as default};
@@ -1 +0,0 @@
1
- import{isLunoraError as f,isInternalCode as l,LunoraError as c}from"@lunora/errors";const u={deny:{code:"FORBIDDEN",status:403},rate:{code:"TOO_MANY_REQUESTS",status:429}},m=(r,a,e)=>a==="deny"?`request denied for "${r}"`:e===void 0?`rate limit "${r}" exceeded`:`rate limit "${r}" exceeded; retry after ${String(e)}ms`,A=(r,a,e={})=>async({ctx:n,next:s})=>{let i;try{i=await(typeof r=="function"?await r(n):r).limit(a,{count:e.count,key:e.key?.(n)})}catch(t){if(f(t)&&l(t.code))throw t;if(console.error(`@lunora/ratelimit: rateLimit("${a}") threw; ${e.failOpen?"failing open":"failing closed"}`,t),e.failOpen)return s();throw new c("SERVICE_UNAVAILABLE",`rate limiter unavailable for "${a}"`,{cause:t,status:503})}if(!i.ok){const t=i.reason??"rate",d=u[t],o=Number.isFinite(i.retryAfter)?Math.ceil(i.retryAfter):void 0;throw new c(d.code,e.message??m(a,t,o),{status:d.status,data:o===void 0?void 0:{retryAfter:o}})}return s()};export{u as STATUS_BY_REASON,A as rateLimit};
@@ -1 +0,0 @@
1
- const r=t=>({key:"ratelimit",middleware:async({ctx:i,next:a})=>{const n=typeof t=="function"?await t(i):t,e=i.api??{};return a({ctx:{api:{...e,ratelimit:n}}})}});export{r as ratelimitPlugin};