@lunora/ratelimit 1.0.0-alpha.7 → 1.0.0-alpha.9

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,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,65 @@ 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>;
78
186
  private resolve;
79
187
  private run;
80
188
  }
189
+ /**
190
+ * Either a fixed {@link RateLimiter} or a function that derives one from `ctx`
191
+ * — the latter lets a procedure bind a durable, ORM-backed limiter at call time
192
+ * (e.g. `(ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) })`).
193
+ */
81
194
  type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
82
195
  interface RateLimitMiddlewareOptions<Context> {
196
+ /** Units to consume per call. Defaults to `1`. */
83
197
  count?: number;
198
+ /**
199
+ * Behavior when the limiter itself throws (store unavailable, etc).
200
+ * Defaults to `false` (fail closed: deny the request with a 503). Set to
201
+ * `true` only when degraded availability is preferable to denying traffic
202
+ * — note that a failing limiter then permits every request through.
203
+ */
84
204
  failOpen?: boolean;
205
+ /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
85
206
  key?: (context: Context) => string | undefined;
207
+ /** Override the error message thrown on rejection. */
86
208
  message?: string;
87
209
  }
210
+ /**
211
+ * Procedure middleware that enforces a named rate limit before the handler
212
+ * runs. Attach it with `.use()`. On rejection it throws a structural
213
+ * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
214
+ * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
215
+ * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
216
+ *
217
+ * **Failure policy:** if resolving or invoking the limiter throws for a genuine
218
+ * availability reason (e.g. the persistence store is unavailable), the
219
+ * middleware **fails closed by default**: it logs via `console.error` and
220
+ * rejects the request with `503`. This is the safer default for
221
+ * security-sensitive limits (auth, account creation). Pass `failOpen: true` to
222
+ * swallow the error and admit the request instead — appropriate only when
223
+ * degraded availability is preferable to refusal. Deterministic caller misuse
224
+ * (an unconfigured limit name, a non-positive count, or a count that exceeds
225
+ * capacity) throws an `INTERNAL` `LunoraError` that is re-thrown as-is under
226
+ * **both** policies — a config bug is never masked as a 503 or silently admitted.
227
+ */
88
228
  declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
229
+ /**
230
+ * In-memory store. State lives for the lifetime of the process (or, inside a
231
+ * Durable Object, the instance) — adequate for single-DO limits but not shared
232
+ * across instances. Use {@link createSqlStore} for durable per-DO state.
233
+ */
89
234
  declare const createMemoryStore: () => RateLimitStore;
235
+ /**
236
+ * Minimal projection of `state.storage.sql` (workerd's `SqlStorage`, also
237
+ * satisfied by `node:sqlite`). Only the `exec` overload is required.
238
+ */
90
239
  interface SqlLike {
91
240
  exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
92
241
  toArray: () => Row[];
@@ -94,9 +243,29 @@ interface SqlLike {
94
243
  }
95
244
  interface SqlStoreOptions {
96
245
  sql: SqlLike;
246
+ /** Table name. Created if missing. Defaults to `_lunora_rate_limits`. */
97
247
  table?: string;
98
248
  }
249
+ /**
250
+ * SQLite-backed store for durable, per-DO rate-limit state. Persists each
251
+ * `(name, key)` pair as one row so limits survive hibernation and eviction.
252
+ *
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.
262
+ */
99
263
  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
+ */
100
269
  interface RateLimitDatabaseIndexRange {
101
270
  eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
102
271
  gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
@@ -104,10 +273,17 @@ interface RateLimitDatabaseIndexRange {
104
273
  lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
105
274
  lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
106
275
  }
276
+ /** The slice of a `ctx.db` table query the store relies on. */
107
277
  interface RateLimitDatabaseQuery {
108
278
  first: () => Promise<Record<string, unknown> | null>;
109
279
  withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
110
280
  }
281
+ /**
282
+ * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
283
+ * needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
284
+ * directly — declared here (rather than imported) to keep `@lunora/ratelimit`
285
+ * free of a runtime dependency on `@lunora/server`.
286
+ */
111
287
  interface RateLimitDatabase {
112
288
  delete: <T extends string>(id: Id<T>) => Promise<void>;
113
289
  insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
@@ -115,22 +291,82 @@ interface RateLimitDatabase {
115
291
  query: (table: string) => RateLimitDatabaseQuery;
116
292
  }
117
293
  interface DatabaseStoreOptions {
294
+ /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
118
295
  db: RateLimitDatabase;
296
+ /** Index that resolves a row by its key column. Defaults to `by_key`. */
119
297
  index?: string;
298
+ /** Column storing the opaque key. Defaults to `key`. */
120
299
  keyField?: string;
300
+ /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
121
301
  table?: string;
122
302
  }
303
+ /**
304
+ * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
305
+ * inside a procedure (the procedure context exposes no raw SQL). Declare a
306
+ * table with the key column and its index, e.g.
307
+ *
308
+ * ```ts
309
+ * rateLimits: defineTable({
310
+ * key: v.string(),
311
+ * ts: v.number(),
312
+ * value: v.number(),
313
+ * prev: v.optional(v.number()),
314
+ * }).index("by_key", ["key"])
315
+ * ```
316
+ *
317
+ * Each operation is a read-then-write; inside a mutation/action that pair runs
318
+ * under the DO's input gate, so it is atomic against concurrent calls.
319
+ */
123
320
  declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
321
+ /**
322
+ * DB-backed rate-limit middleware sugar. Collapses the common
323
+ *
324
+ * ```ts
325
+ * rateLimit((ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) }), name, opts)
326
+ * ```
327
+ *
328
+ * into `dbRateLimit(config, name, opts)`: it builds a per-call {@link RateLimiter}
329
+ * whose accounting lives in a Lunora table via `ctx.db` (so the bucket is durable
330
+ * on the DO the procedure runs on). Every query/mutation/action ctx exposes a
331
+ * compatible `db`, so it slots straight into a `.use(...)` chain.
332
+ *
333
+ * Pass `options.store` to point at a non-default backing table/index/key column
334
+ * (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
335
+ * `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
336
+ * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
337
+ * autocompletes to its declared limit names.
338
+ *
339
+ * Re-exported as `dbRateLimit` from the package root.
340
+ *
341
+ * ```ts
342
+ * const limits = { send: { kind: "token bucket", period: 60_000, rate: 30 } } satisfies RateLimitConfigMap;
343
+ *
344
+ * export const send = mutation
345
+ * .input({ text: v.string() })
346
+ * .use(dbRateLimit(limits, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" }))
347
+ * .mutation(async ({ ctx, args }) => ...);
348
+ * ```
349
+ */
124
350
  declare const databaseRateLimit: <Context extends {
125
351
  db: RateLimitDatabase;
126
352
  }, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
127
353
  store?: Omit<DatabaseStoreOptions, "db">;
128
354
  }) => Middleware<Context, Context>;
355
+ /**
356
+ * Thrown by `RateLimiter.limit` when called with `{ throws: true }`. A
357
+ * `LunoraError` subclass whose code/status track `status.reason`: a rate
358
+ * rejection is `TOO_MANY_REQUESTS`/429, a deny-list hit is `FORBIDDEN`/403 —
359
+ * the same mapping the middleware applies, so both entry points surface the
360
+ * identical wire code (a permanent deny is never a retryable 429). The
361
+ * middleware itself throws a bare structural `LunoraError`, so this is for
362
+ * direct callers that prefer exceptions. Keeps `reason`/`retryAfter`.
363
+ */
129
364
  declare class RateLimitError extends LunoraError {
130
365
  readonly reason: RateLimitReason | undefined;
131
366
  readonly retryAfter: number;
132
367
  constructor(status: RateLimitStatus, message?: string);
133
368
  }
369
+ /** Context shape the plugin middleware widens to: a `ratelimit` limiter on `ctx.api`. */
134
370
  interface RatelimitApiContext<Context> {
135
371
  api: (Context extends {
136
372
  api: infer A;
@@ -138,6 +374,36 @@ interface RatelimitApiContext<Context> {
138
374
  ratelimit: RateLimiter;
139
375
  };
140
376
  }
377
+ /**
378
+ * Package `@lunora/ratelimit` as a first-party {@link Plugin}, the dogfooded
379
+ * form of the plugin contract: instead of (or alongside) the enforcing
380
+ * `rateLimit(...)` middleware, this exposes the resolved {@link RateLimiter}
381
+ * under `ctx.api.ratelimit` so a handler can `limit()`/`check()`/`reset()`
382
+ * programmatically.
383
+ *
384
+ * Install the middleware with one `.use(...)` (or fold it in with
385
+ * `composePluginMiddleware([...])`):
386
+ *
387
+ * ```ts
388
+ * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
389
+ * const c = initLunora.dataModel&lt;DataModel>().create();
390
+ * export const send = c.mutation
391
+ * .use(ratelimitPlugin(limiter).middleware!)
392
+ * .mutation(async ({ ctx, args }) => {
393
+ * const status = await ctx.api.ratelimit.limit("send", { key: ctx.userId });
394
+ * if (!status.ok) throw new Error("slow down");
395
+ * // …
396
+ * });
397
+ * ```
398
+ *
399
+ * The plugin ships no schema extension — the limiter's persistence is whatever
400
+ * store the resolved {@link RateLimiter} was built with — so it is a
401
+ * middleware-only plugin and is skipped by `installPlugins(...)`'s schema fold.
402
+ *
403
+ * Built as a plain {@link Plugin} literal (the key is the fixed string
404
+ * `"ratelimit"`, so the `definePlugin` validation adds nothing) — this keeps
405
+ * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
406
+ */
141
407
  declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
142
408
  declare const VERSION = "0.0.0";
143
409
  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 };
package/dist/index.d.ts 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,65 @@ 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>;
78
186
  private resolve;
79
187
  private run;
80
188
  }
189
+ /**
190
+ * Either a fixed {@link RateLimiter} or a function that derives one from `ctx`
191
+ * — the latter lets a procedure bind a durable, ORM-backed limiter at call time
192
+ * (e.g. `(ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) })`).
193
+ */
81
194
  type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
82
195
  interface RateLimitMiddlewareOptions<Context> {
196
+ /** Units to consume per call. Defaults to `1`. */
83
197
  count?: number;
198
+ /**
199
+ * Behavior when the limiter itself throws (store unavailable, etc).
200
+ * Defaults to `false` (fail closed: deny the request with a 503). Set to
201
+ * `true` only when degraded availability is preferable to denying traffic
202
+ * — note that a failing limiter then permits every request through.
203
+ */
84
204
  failOpen?: boolean;
205
+ /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
85
206
  key?: (context: Context) => string | undefined;
207
+ /** Override the error message thrown on rejection. */
86
208
  message?: string;
87
209
  }
210
+ /**
211
+ * Procedure middleware that enforces a named rate limit before the handler
212
+ * runs. Attach it with `.use()`. On rejection it throws a structural
213
+ * `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
214
+ * hits) carrying `retryAfter` in milliseconds — the runtime maps it to the
215
+ * matching RPC/HTTP status without any import of `@lunora/server` at runtime.
216
+ *
217
+ * **Failure policy:** if resolving or invoking the limiter throws for a genuine
218
+ * availability reason (e.g. the persistence store is unavailable), the
219
+ * middleware **fails closed by default**: it logs via `console.error` and
220
+ * rejects the request with `503`. This is the safer default for
221
+ * security-sensitive limits (auth, account creation). Pass `failOpen: true` to
222
+ * swallow the error and admit the request instead — appropriate only when
223
+ * degraded availability is preferable to refusal. Deterministic caller misuse
224
+ * (an unconfigured limit name, a non-positive count, or a count that exceeds
225
+ * capacity) throws an `INTERNAL` `LunoraError` that is re-thrown as-is under
226
+ * **both** policies — a config bug is never masked as a 503 or silently admitted.
227
+ */
88
228
  declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
229
+ /**
230
+ * In-memory store. State lives for the lifetime of the process (or, inside a
231
+ * Durable Object, the instance) — adequate for single-DO limits but not shared
232
+ * across instances. Use {@link createSqlStore} for durable per-DO state.
233
+ */
89
234
  declare const createMemoryStore: () => RateLimitStore;
235
+ /**
236
+ * Minimal projection of `state.storage.sql` (workerd's `SqlStorage`, also
237
+ * satisfied by `node:sqlite`). Only the `exec` overload is required.
238
+ */
90
239
  interface SqlLike {
91
240
  exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
92
241
  toArray: () => Row[];
@@ -94,9 +243,29 @@ interface SqlLike {
94
243
  }
95
244
  interface SqlStoreOptions {
96
245
  sql: SqlLike;
246
+ /** Table name. Created if missing. Defaults to `_lunora_rate_limits`. */
97
247
  table?: string;
98
248
  }
249
+ /**
250
+ * SQLite-backed store for durable, per-DO rate-limit state. Persists each
251
+ * `(name, key)` pair as one row so limits survive hibernation and eviction.
252
+ *
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.
262
+ */
99
263
  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
+ */
100
269
  interface RateLimitDatabaseIndexRange {
101
270
  eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
102
271
  gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
@@ -104,10 +273,17 @@ interface RateLimitDatabaseIndexRange {
104
273
  lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
105
274
  lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
106
275
  }
276
+ /** The slice of a `ctx.db` table query the store relies on. */
107
277
  interface RateLimitDatabaseQuery {
108
278
  first: () => Promise<Record<string, unknown> | null>;
109
279
  withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
110
280
  }
281
+ /**
282
+ * The slice of the Lunora ORM writer (`ctx.db` on a mutation/action) the store
283
+ * needs. The real `DatabaseWriter` is structurally assignable, so pass `ctx.db`
284
+ * directly — declared here (rather than imported) to keep `@lunora/ratelimit`
285
+ * free of a runtime dependency on `@lunora/server`.
286
+ */
111
287
  interface RateLimitDatabase {
112
288
  delete: <T extends string>(id: Id<T>) => Promise<void>;
113
289
  insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
@@ -115,22 +291,82 @@ interface RateLimitDatabase {
115
291
  query: (table: string) => RateLimitDatabaseQuery;
116
292
  }
117
293
  interface DatabaseStoreOptions {
294
+ /** The Lunora ORM writer — `ctx.db` inside a mutation or action. */
118
295
  db: RateLimitDatabase;
296
+ /** Index that resolves a row by its key column. Defaults to `by_key`. */
119
297
  index?: string;
298
+ /** Column storing the opaque key. Defaults to `key`. */
120
299
  keyField?: string;
300
+ /** Table holding one row per `(name, key)` pair. Defaults to `rateLimits`. */
121
301
  table?: string;
122
302
  }
303
+ /**
304
+ * Store backed by a Lunora table through `ctx.db`, for durable per-DO limits
305
+ * inside a procedure (the procedure context exposes no raw SQL). Declare a
306
+ * table with the key column and its index, e.g.
307
+ *
308
+ * ```ts
309
+ * rateLimits: defineTable({
310
+ * key: v.string(),
311
+ * ts: v.number(),
312
+ * value: v.number(),
313
+ * prev: v.optional(v.number()),
314
+ * }).index("by_key", ["key"])
315
+ * ```
316
+ *
317
+ * Each operation is a read-then-write; inside a mutation/action that pair runs
318
+ * under the DO's input gate, so it is atomic against concurrent calls.
319
+ */
123
320
  declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
321
+ /**
322
+ * DB-backed rate-limit middleware sugar. Collapses the common
323
+ *
324
+ * ```ts
325
+ * rateLimit((ctx) => new RateLimiter({ config, store: createDbStore({ db: ctx.db }) }), name, opts)
326
+ * ```
327
+ *
328
+ * into `dbRateLimit(config, name, opts)`: it builds a per-call {@link RateLimiter}
329
+ * whose accounting lives in a Lunora table via `ctx.db` (so the bucket is durable
330
+ * on the DO the procedure runs on). Every query/mutation/action ctx exposes a
331
+ * compatible `db`, so it slots straight into a `.use(...)` chain.
332
+ *
333
+ * Pass `options.store` to point at a non-default backing table/index/key column
334
+ * (defaults: table `rateLimits`, index `by_key`, key column `key`); the rest of
335
+ * `options` (`key`, `count`, `failOpen`, `message`) is forwarded to
336
+ * {@link rateLimit} unchanged. When `config` is precisely typed, `name`
337
+ * autocompletes to its declared limit names.
338
+ *
339
+ * Re-exported as `dbRateLimit` from the package root.
340
+ *
341
+ * ```ts
342
+ * const limits = { send: { kind: "token bucket", period: 60_000, rate: 30 } } satisfies RateLimitConfigMap;
343
+ *
344
+ * export const send = mutation
345
+ * .input({ text: v.string() })
346
+ * .use(dbRateLimit(limits, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" }))
347
+ * .mutation(async ({ ctx, args }) => ...);
348
+ * ```
349
+ */
124
350
  declare const databaseRateLimit: <Context extends {
125
351
  db: RateLimitDatabase;
126
352
  }, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
127
353
  store?: Omit<DatabaseStoreOptions, "db">;
128
354
  }) => Middleware<Context, Context>;
355
+ /**
356
+ * Thrown by `RateLimiter.limit` when called with `{ throws: true }`. A
357
+ * `LunoraError` subclass whose code/status track `status.reason`: a rate
358
+ * rejection is `TOO_MANY_REQUESTS`/429, a deny-list hit is `FORBIDDEN`/403 —
359
+ * the same mapping the middleware applies, so both entry points surface the
360
+ * identical wire code (a permanent deny is never a retryable 429). The
361
+ * middleware itself throws a bare structural `LunoraError`, so this is for
362
+ * direct callers that prefer exceptions. Keeps `reason`/`retryAfter`.
363
+ */
129
364
  declare class RateLimitError extends LunoraError {
130
365
  readonly reason: RateLimitReason | undefined;
131
366
  readonly retryAfter: number;
132
367
  constructor(status: RateLimitStatus, message?: string);
133
368
  }
369
+ /** Context shape the plugin middleware widens to: a `ratelimit` limiter on `ctx.api`. */
134
370
  interface RatelimitApiContext<Context> {
135
371
  api: (Context extends {
136
372
  api: infer A;
@@ -138,6 +374,36 @@ interface RatelimitApiContext<Context> {
138
374
  ratelimit: RateLimiter;
139
375
  };
140
376
  }
377
+ /**
378
+ * Package `@lunora/ratelimit` as a first-party {@link Plugin}, the dogfooded
379
+ * form of the plugin contract: instead of (or alongside) the enforcing
380
+ * `rateLimit(...)` middleware, this exposes the resolved {@link RateLimiter}
381
+ * under `ctx.api.ratelimit` so a handler can `limit()`/`check()`/`reset()`
382
+ * programmatically.
383
+ *
384
+ * Install the middleware with one `.use(...)` (or fold it in with
385
+ * `composePluginMiddleware([...])`):
386
+ *
387
+ * ```ts
388
+ * const limiter = new RateLimiter({ config: { send: { kind: "token bucket", rate: 5, period: 60_000, capacity: 5 } } });
389
+ * const c = initLunora.dataModel&lt;DataModel>().create();
390
+ * export const send = c.mutation
391
+ * .use(ratelimitPlugin(limiter).middleware!)
392
+ * .mutation(async ({ ctx, args }) => {
393
+ * const status = await ctx.api.ratelimit.limit("send", { key: ctx.userId });
394
+ * if (!status.ok) throw new Error("slow down");
395
+ * // …
396
+ * });
397
+ * ```
398
+ *
399
+ * The plugin ships no schema extension — the limiter's persistence is whatever
400
+ * store the resolved {@link RateLimiter} was built with — so it is a
401
+ * middleware-only plugin and is skipped by `installPlugins(...)`'s schema fold.
402
+ *
403
+ * Built as a plain {@link Plugin} literal (the key is the fixed string
404
+ * `"ratelimit"`, so the `definePlugin` validation adds nothing) — this keeps
405
+ * `@lunora/server` a type-only dependency of `@lunora/ratelimit`.
406
+ */
141
407
  declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
142
408
  declare const VERSION = "0.0.0";
143
409
  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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/ratelimit",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, sharding, pluggable stores, and procedure middleware",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.4"
49
+ "@lunora/errors": "1.0.0-alpha.6"
50
50
  },
51
51
  "engines": {
52
52
  "node": "^22.15.0 || >=24.11.0"