@lunora/ratelimit 1.0.0-alpha.4 → 1.0.0-alpha.41

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