@lunora/ratelimit 1.0.0-alpha.1 → 1.0.0-alpha.100

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