@lunora/ratelimit 1.0.0-alpha.3 → 1.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md CHANGED
@@ -103,3 +103,9 @@ Unless required by applicable law or agreed to in writing, software distributed
103
103
  under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
104
  CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
105
  specific language governing permissions and limitations under the License.
106
+
107
+ <!-- DEPENDENCIES -->
108
+ <!-- /DEPENDENCIES -->
109
+
110
+ <!-- TYPE_DEPENDENCIES -->
111
+ <!-- /TYPE_DEPENDENCIES -->
package/README.md CHANGED
@@ -59,37 +59,55 @@ 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
+ export const send = mutation.use(dbRateLimit(config, "send", { key: (ctx) => ctx.auth.userId })).mutation(async ({ ctx }) => {
75
80
  // …
76
81
  });
77
82
  ```
78
83
 
79
- Or call the limiter directly:
84
+ Or call the limiter directly, inside a mutation/action so `ctx.db` is available:
80
85
 
81
86
  ```ts
82
- const status = await limiter.limit("send", { key: userId });
87
+ import { RateLimiter, RateLimitError, createDbStore } from "@lunora/ratelimit";
88
+
89
+ export const login = mutation.mutation(async ({ ctx, args }) => {
90
+ const limiter = new RateLimiter({ config, store: createDbStore({ db: ctx.db }) });
91
+ const status = await limiter.limit("login", { key: args.email });
83
92
 
84
- if (!status.ok) {
85
- // status.retryAfter is milliseconds until the request would succeed.
86
- // status.reason is "rate" or "deny".
87
- }
93
+ if (!status.ok) {
94
+ // Stop here `limit` returns a failing status by default rather than
95
+ // throwing, so falling through authenticates the rejected attempt.
96
+ // status.retryAfter is milliseconds until the request would succeed.
97
+ // status.reason is "rate" or "deny".
98
+ throw new RateLimitError(status);
99
+ }
100
+
101
+ await authenticate(args);
102
+ });
88
103
  ```
89
104
 
90
105
  For durable per-DO state inside a procedure, back the limiter with a Lunora
91
106
  table via `dbRateLimit(config, name, options)`, or supply a `store`
92
- (`createMemoryStore` / `createSqlStore` / `createDbStore`).
107
+ (`createSqlStore` / `createDbStore`). `createMemoryStore` stays the default —
108
+ correct inside a single Durable Object — but constructing a `RateLimiter` with
109
+ no explicit `store` now warns once so that choice is visible instead of
110
+ silent.
93
111
 
94
112
  > This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs)**.
95
113
 
package/dist/index.d.mts CHANGED
@@ -1,60 +1,158 @@
1
1
  import { Middleware, Plugin } from '@lunora/server';
2
2
  import { Id } from '@lunora/values';
3
+ import { LunoraError } from '@lunora/errors';
4
+ /** Rate-limit algorithm. */
3
5
  type RateLimitKind = "fixed window" | "sliding window" | "token bucket";
6
+ /** Why a request was denied. */
4
7
  type RateLimitReason = "deny" | "rate";
8
+ /** Definition of a single named rate limit. */
5
9
  interface RateLimitConfig {
10
+ /**
11
+ * Maximum tokens that can accumulate (the rollover ceiling). Defaults to
12
+ * `rate`: for a token bucket that caps a burst at one period's worth of
13
+ * tokens, and for a fixed window it disables cross-window rollover. Ignored
14
+ * by sliding windows, which always cap at `rate` per `period`.
15
+ */
6
16
  capacity?: number;
7
17
  kind: RateLimitKind;
18
+ /** Window/refill period in milliseconds. */
8
19
  period: number;
20
+ /** Tokens granted per `period`. */
9
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
+ */
10
33
  shards?: number;
34
+ /**
35
+ * Phase offset in epoch milliseconds for windowed algorithms — windows
36
+ * align to `start + n * period`. Ignored by token buckets. Defaults to `0`.
37
+ */
11
38
  start?: number;
12
39
  }
40
+ /** A map of limit name to its config, used to construct a `RateLimiter`. */
13
41
  type RateLimitConfigMap<Names extends string = string> = Record<Names, RateLimitConfig>;
42
+ /** Persisted accounting state for one `(name, key)` pair. */
14
43
  interface RateLimitValue {
44
+ /**
45
+ * Sliding window only: request count from the previous window, used to
46
+ * weight the current estimate. Unset for token-bucket / fixed-window.
47
+ */
15
48
  prev?: number;
49
+ /**
50
+ * Token-bucket: timestamp of the last refill. Fixed/sliding window: start of
51
+ * the window the value belongs to.
52
+ */
16
53
  ts: number;
54
+ /**
55
+ * Tokens available (token bucket), tokens remaining in the window (fixed
56
+ * window), or requests made in the current window (sliding window).
57
+ * Fractional for token buckets; negative when reserved ahead.
58
+ */
17
59
  value: number;
18
60
  }
61
+ /** Outcome of a `RateLimiter.limit` / `RateLimiter.check` call. */
19
62
  interface RateLimitStatus {
63
+ /** Whether the request is permitted. */
20
64
  ok: boolean;
65
+ /** Why the request was denied. Absent when `ok`. */
21
66
  reason?: RateLimitReason;
67
+ /** Milliseconds until the request would succeed. `0` when `ok` without reservation. */
22
68
  retryAfter: number;
23
69
  }
70
+ /** Per-call options for `RateLimiter.limit`. */
24
71
  interface RateLimitArgs {
72
+ /** Units to consume. Defaults to `1`. */
25
73
  count?: number;
74
+ /** Sub-key isolating the limit (per user/team/IP). Omit for a global limit. */
26
75
  key?: string;
76
+ /**
77
+ * Permit the request even when capacity is insufficient, reserving future
78
+ * capacity (the stored value goes negative). `retryAfter` then reports when
79
+ * the debt clears. Rejected only when `count` exceeds the bucket capacity.
80
+ */
27
81
  reserve?: boolean;
82
+ /** Throw `RateLimitError` instead of returning a failing status. */
28
83
  throws?: boolean;
29
84
  }
85
+ /**
86
+ * Pluggable persistence. Reads and writes are keyed by an opaque storage key
87
+ * the limiter derives from the limit name and `key`. Implementations may be
88
+ * synchronous (in-memory) or asynchronous (SQLite/KV); the limiter awaits
89
+ * either.
90
+ */
30
91
  interface RateLimitStore {
31
92
  delete: (storageKey: string) => Promise<void> | void;
32
93
  get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
33
94
  set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
34
95
  }
96
+ /** Inputs to {@link evaluate}. */
35
97
  interface EvaluateOptions {
98
+ /** When `false`, compute status without consuming (a `check`). */
36
99
  consume: boolean;
100
+ /** Units requested. */
37
101
  count: number;
102
+ /** Current time in epoch milliseconds. */
38
103
  now: number;
104
+ /** Permit a deficit by reserving future capacity (token bucket / within-window). */
39
105
  reserve: boolean;
40
106
  }
107
+ /** Result of evaluating a limit against its prior state. */
41
108
  interface EvaluateResult {
42
109
  status: RateLimitStatus;
110
+ /** Next value to persist, or `undefined` when the call must not mutate state. */
43
111
  value: RateLimitValue | undefined;
44
112
  }
113
+ /**
114
+ * Project a limit's stored state forward to `now` without consuming: how many
115
+ * units could be admitted right now. Token bucket → the refilled token count;
116
+ * fixed window → tokens left in the current (possibly rolled-over) window;
117
+ * sliding window → `rate` minus the weighted estimate, floored at zero. Pure,
118
+ * like {@link evaluate}, and shares its per-algorithm projection helpers so the
119
+ * two never diverge. Backs `RateLimiter.getValue` so it reports a live figure
120
+ * rather than the last value that happened to be persisted.
121
+ */
45
122
  declare const availableAt: (config: RateLimitConfig, prior: RateLimitValue | undefined, now: number) => {
46
123
  ts: number;
47
124
  value: number;
48
125
  };
126
+ /**
127
+ * Evaluate a request against a limit's prior state. Pure: it never reads a
128
+ * clock or persists — the caller supplies `now` and writes back `value` when
129
+ * it is not `undefined`.
130
+ */
49
131
  declare const evaluate: (config: RateLimitConfig, prior: RateLimitValue | undefined, options: EvaluateOptions) => EvaluateResult;
50
132
  interface RateLimiterOptions<Names extends string> {
51
133
  config: RateLimitConfigMap<Names>;
134
+ /** Keys that are always denied, regardless of limit state. */
52
135
  denyList?: Iterable<string>;
136
+ /**
137
+ * 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
141
+ * itself is consulted as-is; normalize the deny-list entries up front to
142
+ * match.
143
+ */
53
144
  normalize?: (key: string) => string;
145
+ /** Clock injection for tests. Defaults to `Date.now`. */
54
146
  now?: () => number;
55
- random?: () => number;
147
+ /** Persistence. Defaults to a per-instance in-memory store. */
56
148
  store?: RateLimitStore;
57
149
  }
150
+ /**
151
+ * Enforces named rate limits over a pluggable store. Construct one per app with
152
+ * a config map; call {@link RateLimiter.limit} to consume and
153
+ * {@link RateLimiter.check} to peek. Framework-agnostic — the `@lunora/ratelimit`
154
+ * middleware wraps it for procedures.
155
+ */
58
156
  declare class RateLimiter<Names extends string = string> {
59
157
  private readonly config;
60
158
  private readonly denyList;
@@ -62,7 +160,16 @@ declare class RateLimiter<Names extends string = string> {
62
160
  private readonly now;
63
161
  private readonly store;
64
162
  constructor(options: RateLimiterOptions<Names>);
163
+ /** Peek at whether a request would be permitted without consuming. */
65
164
  check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
165
+ /**
166
+ * Read the current config and the units admittable right now for a
167
+ * `(name, key)` pair. The value is projected forward to the current clock
168
+ * (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.
172
+ */
66
173
  getValue(name: Names, args?: {
67
174
  key?: string;
68
175
  }): Promise<{
@@ -70,22 +177,66 @@ declare class RateLimiter<Names extends string = string> {
70
177
  ts: number;
71
178
  value: number;
72
179
  }>;
180
+ /** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
73
181
  limit(name: Names, args?: RateLimitArgs): Promise<RateLimitStatus>;
182
+ /** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
74
183
  reset(name: Names, args?: {
75
184
  key?: string;
76
185
  }): Promise<void>;
186
+ private normalizeKey;
77
187
  private resolve;
78
188
  private run;
79
189
  }
190
+ /**
191
+ * Either a fixed {@link RateLimiter} or a function that derives one from `ctx`
192
+ * — the latter lets a procedure bind a durable, ORM-backed limiter at call time
193
+ * (e.g. `(ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) })`).
194
+ */
80
195
  type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
81
196
  interface RateLimitMiddlewareOptions<Context> {
197
+ /** Units to consume per call. Defaults to `1`. */
82
198
  count?: number;
199
+ /**
200
+ * Behavior when the limiter itself throws (store unavailable, etc).
201
+ * Defaults to `false` (fail closed: deny the request with a 503). Set to
202
+ * `true` only when degraded availability is preferable to denying traffic
203
+ * — note that a failing limiter then permits every request through.
204
+ */
83
205
  failOpen?: boolean;
206
+ /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
84
207
  key?: (context: Context) => string | undefined;
208
+ /** Override the error message thrown on rejection. */
85
209
  message?: string;
86
210
  }
211
+ /**
212
+ * Procedure middleware that enforces a named rate limit before the handler
213
+ * runs. Attach it with `.use()`. On rejection it throws a structural
214
+ * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
215
+ * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
216
+ * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
217
+ *
218
+ * **Failure policy:** if resolving or invoking the limiter throws for a genuine
219
+ * availability reason (e.g. the persistence store is unavailable), the
220
+ * middleware **fails closed by default**: it logs via `console.error` and
221
+ * rejects the request with `503`. This is the safer default for
222
+ * security-sensitive limits (auth, account creation). Pass `failOpen: true` to
223
+ * swallow the error and admit the request instead — appropriate only when
224
+ * degraded availability is preferable to refusal. Deterministic caller misuse
225
+ * (an unconfigured limit name, a non-positive count, or a count that exceeds
226
+ * capacity) throws an `INTERNAL` `LunoraError` that is re-thrown as-is under
227
+ * **both** policies — a config bug is never masked as a 503 or silently admitted.
228
+ */
87
229
  declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
230
+ /**
231
+ * In-memory store. State lives for the lifetime of the process (or, inside a
232
+ * Durable Object, the instance) — adequate for single-DO limits but not shared
233
+ * across instances. Use {@link createSqlStore} for durable per-DO state.
234
+ */
88
235
  declare const createMemoryStore: () => RateLimitStore;
236
+ /**
237
+ * Minimal projection of `state.storage.sql` (workerd's `SqlStorage`, also
238
+ * satisfied by `node:sqlite`). Only the `exec` overload is required.
239
+ */
89
240
  interface SqlLike {
90
241
  exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
91
242
  toArray: () => Row[];
@@ -93,9 +244,29 @@ interface SqlLike {
93
244
  }
94
245
  interface SqlStoreOptions {
95
246
  sql: SqlLike;
247
+ /** Table name. Created if missing. Defaults to `_lunora_rate_limits`. */
96
248
  table?: string;
97
249
  }
250
+ /**
251
+ * SQLite-backed store for durable, per-DO rate-limit state. Persists each
252
+ * `(name, key)` pair as one row so limits survive hibernation and eviction.
253
+ *
254
+ * **Atomicity:** the store does not wrap individual operations in an explicit
255
+ * SQL transaction. Inside a Durable Object the DO's input gate serializes
256
+ * every RPC call against the storage, so the limiter's read-modify-write
257
+ * sequence runs to completion without interleaving — this is the same
258
+ * guarantee the surrounding `evaluate()` step depends on. **Outside a DO**
259
+ * (e.g. driving `createSqlStore` from a long-lived `node:sqlite` connection in
260
+ * tests or a custom host) the caller is responsible for serialization; the
261
+ * SQL surface used here (`exec`) is not a substitute for transactional
262
+ * isolation across concurrent invocations.
263
+ */
98
264
  declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
265
+ /**
266
+ * The slice of an index-range builder the store uses. Mirrors `@lunora/server`'s
267
+ * `IndexRangeBuilder` field-for-field so the real `ctx.db` query builder is
268
+ * assignable; only `eq` is exercised.
269
+ */
99
270
  interface RateLimitDatabaseIndexRange {
100
271
  eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
101
272
  gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
@@ -103,34 +274,134 @@ interface RateLimitDatabaseIndexRange {
103
274
  lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
104
275
  lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
105
276
  }
277
+ /** The slice of a `ctx.db` table query the store relies on. */
106
278
  interface RateLimitDatabaseQuery {
107
279
  first: () => Promise<Record<string, unknown> | null>;
108
280
  withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
109
281
  }
110
- interface RateLimitDatabase {
282
+ /**
283
+ * The READ slice — everything the store needs to answer `RateLimiter.getValue`
284
+ * and `check`, which the docs describe as projecting the stored value forward to
285
+ * the current clock. A `QueryCtx`'s `ctx.db` is a reader and satisfies this.
286
+ *
287
+ * Split out because requiring the writer for a pure read meant "how many
288
+ * requests does this user have left" could not be answered from a query context
289
+ * at all — every remaining-quota display had to cast, and a cast that appears
290
+ * often enough stops carrying information. The distinction was already in the
291
+ * methods: `getValue`/`check` read, `limit`/`reset` write.
292
+ */
293
+ interface RateLimitDatabaseReader {
294
+ query: (table: string) => RateLimitDatabaseQuery;
295
+ }
296
+ /**
297
+ * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
298
+ * needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
299
+ * directly — declared here (rather than imported) to keep `@lunora/ratelimit`
300
+ * free of a runtime dependency on `@lunora/server`.
301
+ */
302
+ interface RateLimitDatabase extends RateLimitDatabaseReader {
111
303
  delete: <T extends string>(id: Id<T>) => Promise<void>;
112
304
  insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
113
305
  patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
114
- query: (table: string) => RateLimitDatabaseQuery;
115
306
  }
116
- interface DatabaseStoreOptions {
117
- db: RateLimitDatabase;
307
+ /** The table/column/index knobs shared by the read-only and read-write stores. */
308
+ interface DatabaseStoreLocation {
309
+ /** Index that resolves a row by its key column. Defaults to `by_key`. */
118
310
  index?: string;
311
+ /** Column storing the opaque key. Defaults to `key`. */
119
312
  keyField?: string;
313
+ /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
120
314
  table?: string;
121
315
  }
316
+ interface DatabaseStoreOptions extends DatabaseStoreLocation {
317
+ /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
318
+ db: RateLimitDatabase;
319
+ }
320
+ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
321
+ /** The Lunora ORM reader — `ctx.db` inside a query. */
322
+ db: RateLimitDatabaseReader;
323
+ }
324
+ /**
325
+ * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
326
+ * inside a procedure (the procedure context exposes no raw SQL). Declare a
327
+ * table with the key column and its index, e.g.
328
+ *
329
+ * ```ts
330
+ * rateLimits: defineTable({
331
+ * key: v.string(),
332
+ * ts: v.number(),
333
+ * value: v.number(),
334
+ * prev: v.optional(v.number()),
335
+ * }).index("by_key", ["key"])
336
+ * ```
337
+ *
338
+ * Each operation is a read-then-write; inside a mutation/action that pair runs
339
+ * under the DO's input gate, so it is atomic against concurrent calls.
340
+ */
122
341
  declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
342
+ /**
343
+ * Read-only counterpart to {@link createDatabaseStore}, for a query context.
344
+ *
345
+ * `get` behaves identically — same table, index and key column — so
346
+ * `RateLimiter.getValue` / `check` report exactly what the writing store would.
347
+ * `set` and `delete` are the only difference: they throw rather than silently
348
+ * doing nothing, because a limiter that appears to consume budget and does not
349
+ * is worse than one that refuses.
350
+ *
351
+ * This mirrors the split Lunora already makes for `ctx.storage`, which is a
352
+ * `ReadOnlyStorage` in a query and a full `Storage` in an action — the
353
+ * capability difference is visible in the type instead of discovered at runtime.
354
+ */
355
+ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOptions) => RateLimitStore;
356
+ /**
357
+ * DB-backed rate-limit middleware sugar. Collapses the common
358
+ *
359
+ * ```ts
360
+ * rateLimit((ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) }), name, opts)
361
+ * ```
362
+ *
363
+ * into `dbRateLimit(config, name, opts)`: it builds a per-call {@link RateLimiter}
364
+ * whose accounting lives in a Lunora table via `ctx.db` (so the bucket is durable
365
+ * on the DO the procedure runs on). Every query/mutation/action ctx exposes a
366
+ * compatible `db`, so it slots straight into a `.use(...)` chain.
367
+ *
368
+ * Pass `options.store` to point at a non-default backing table/index/key column
369
+ * (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
370
+ * `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
371
+ * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
372
+ * autocompletes to its declared limit names.
373
+ *
374
+ * Re-exported as `dbRateLimit` from the package root.
375
+ *
376
+ * ```ts
377
+ * const limits = { send: { kind: "token bucket", period: 60_000, rate: 30 } } satisfies RateLimitConfigMap;
378
+ *
379
+ * export const send = mutation
380
+ * .input({ text: v.string() })
381
+ * .use(dbRateLimit(limits, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" }))
382
+ * .mutation(async ({ ctx, args }) => ...);
383
+ * ```
384
+ */
123
385
  declare const databaseRateLimit: <Context extends {
124
386
  db: RateLimitDatabase;
125
387
  }, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
126
388
  store?: Omit<DatabaseStoreOptions, "db">;
127
389
  }) => Middleware<Context, Context>;
128
- declare class RateLimitError extends Error {
129
- readonly name = "RateLimitError";
390
+ /**
391
+ * Thrown by `RateLimiter.limit` when called with `{ throws: true }`. A
392
+ * `LunoraError` subclass whose code/status track `status.reason`: a rate
393
+ * rejection is `TOO_MANY_REQUESTS`/429, a deny-list hit is `FORBIDDEN`/403 —
394
+ * the same mapping the middleware applies, so both entry points surface the
395
+ * identical wire code (a permanent deny is never a retryable 429). The
396
+ * middleware itself throws a bare structural `LunoraError`, so this is for
397
+ * direct callers that prefer exceptions. Keeps `reason`/`retryAfter`.
398
+ */
399
+ declare class RateLimitError extends LunoraError {
130
400
  readonly reason: RateLimitReason | undefined;
131
401
  readonly retryAfter: number;
132
402
  constructor(status: RateLimitStatus, message?: string);
133
403
  }
404
+ /** Context shape the plugin middleware widens to: a `ratelimit` limiter on `ctx.api`. */
134
405
  interface RatelimitApiContext<Context> {
135
406
  api: (Context extends {
136
407
  api: infer A;
@@ -138,6 +409,81 @@ interface RatelimitApiContext<Context> {
138
409
  ratelimit: RateLimiter;
139
410
  };
140
411
  }
412
+ /**
413
+ * Package `@lunora/ratelimit` as a first-party {@link Plugin}, the dogfooded
414
+ * form of the plugin contract: instead of (or alongside) the enforcing
415
+ * `rateLimit(...)` middleware, this exposes the resolved {@link RateLimiter}
416
+ * under `ctx.api.ratelimit` so a handler can `limit()`/`check()`/`reset()`
417
+ * programmatically.
418
+ *
419
+ * Install the middleware with one `.use(...)` (or fold it in with
420
+ * `composePluginMiddleware([...])`):
421
+ *
422
+ * ```ts
423
+ * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
424
+ * const c = initLunora.dataModel<DataModel>().create();
425
+ * export const send = c.mutation
426
+ * .use(ratelimitPlugin(limiter).middleware!)
427
+ * .mutation(async ({ ctx, args }) => {
428
+ * const status = await ctx.api.ratelimit.limit("send", { key: ctx.userId });
429
+ * if (!status.ok) throw new Error("slow down");
430
+ * // …
431
+ * });
432
+ * ```
433
+ *
434
+ * The plugin ships no schema extension — the limiter's persistence is whatever
435
+ * store the resolved {@link RateLimiter} was built with — so it is a
436
+ * middleware-only plugin and is skipped by `installPlugins(...)`'s schema fold.
437
+ *
438
+ * Built as a plain {@link Plugin} literal (the key is the fixed string
439
+ * `"ratelimit"`, so the `definePlugin` validation adds nothing) — this keeps
440
+ * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
441
+ */
141
442
  declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
443
+ /** A budget bound to one named limit — check before the call, record after it. */
444
+ interface TokenBudget {
445
+ /**
446
+ * Peek at the budget before spending. `ok: false` means it is exhausted:
447
+ * refuse the call, and `retryAfter` says when it refills. Consumes nothing.
448
+ */
449
+ check: (key: string) => Promise<RateLimitStatus>;
450
+ /**
451
+ * Charge the tokens a call actually used. Always call it, including when the
452
+ * call THREW — a failed generation that consumed input tokens still has to be
453
+ * paid for. `tokens` of `0` is a no-op, so a call that spent nothing costs
454
+ * nothing.
455
+ *
456
+ * The charge is a reservation, so it may take the bucket negative: the tokens
457
+ * are already spent, and refusing to record them would let a single oversized
458
+ * call escape the budget entirely.
459
+ */
460
+ record: (key: string, tokens: number) => Promise<RateLimitStatus>;
461
+ }
462
+ /**
463
+ * Bind a {@link TokenBudget} to one of a limiter's named limits.
464
+ *
465
+ * ```ts
466
+ * const budget = tokenBudget(limiter, "tokens");
467
+ * const allowed = await budget.check(userId);
468
+ *
469
+ * if (!allowed.ok) {
470
+ * throw new LunoraError("RATE_LIMITED", `token budget exhausted; retry in ${String(allowed.retryAfter)}ms`);
471
+ * }
472
+ *
473
+ * try {
474
+ * const { text, usage } = await generateText({ model: ctx.ai.model(), prompt });
475
+ *
476
+ * await budget.record(userId, usage?.totalTokens ?? 0);
477
+ *
478
+ * return text;
479
+ * } catch (error) {
480
+ * // The prompt was still sent — charge what is known, then rethrow.
481
+ * await budget.record(userId, estimatedInputTokens);
482
+ *
483
+ * throw error;
484
+ * }
485
+ * ```
486
+ */
487
+ declare const tokenBudget: <Names extends string>(limiter: RateLimiter<Names>, name: Names) => TokenBudget;
142
488
  declare const VERSION = "0.0.0";
143
- 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, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type SqlLike, type SqlStoreOptions, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin };
489
+ 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, type TokenBudget, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createReadOnlyDatabaseStore as createReadOnlyDbStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin, tokenBudget };