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

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
@@ -94,16 +94,20 @@ interface RateLimitStore {
94
94
  setIf?: (storageKey: string, value: RateLimitValue, prior: RateLimitValue | undefined) => Promise<boolean>;
95
95
  /**
96
96
  * Optional eviction hint, called by the limiter after each successful write.
97
- * Examine up to `limit` entries from the store's oldest end and remove those
98
- * whose `ts` is at or before `olderThanTs`, except entries with a negative
99
- * `value`: a reserved debt is kept, whatever its age. `limit` caps entries
100
- * examined, not entries removed. Omit it when the store's rows must persist.
97
+ * Remove up to `limit` entries whose `ts` is at or before `olderThanTs`, except
98
+ * entries with a negative `value`: a reserved debt is kept, whatever its age.
99
+ * The memory store examines up to `limit` entries from its oldest end; the
100
+ * durable stores delete at most `limit` matching rows. A returned promise is
101
+ * awaited, so a store whose sweep is I/O (`createDbStore`) finishes it before the
102
+ * consume returns. The durable stores implement it so a table stays bounded
103
+ * without a scheduled job; see `createSqlStore` and `createDbStore` for the
104
+ * table shape.
101
105
  *
102
106
  * `owns` says whether a storage key belongs to the limiter asking for the
103
107
  * sweep. A store shared with another limiter must leave the other limiter's
104
108
  * keys alone: its horizon is not this one's. Absent means every key is owned.
105
109
  */
106
- sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => void;
110
+ sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => Promise<void> | void;
107
111
  }
108
112
  /** Inputs to {@link evaluate}. */
109
113
  interface EvaluateOptions {
@@ -210,6 +214,12 @@ declare class RateLimiter<Names extends string = string> {
210
214
  * than admitted.
211
215
  */
212
216
  private decide;
217
+ /**
218
+ * Evict what this limiter's horizon has expired, after a write has landed. The
219
+ * write is already done, so awaiting here does not reopen the read-decide-write
220
+ * window. Only a promise-returning sweep (`createDbStore`) is awaited for I/O.
221
+ */
222
+ private sweep;
213
223
  private normalizeKey;
214
224
  private resolve;
215
225
  private run;
@@ -289,6 +299,15 @@ interface SqlStoreOptions {
289
299
  * SQLite-backed store for durable, per-DO rate-limit state. Persists each
290
300
  * `(name, key)` pair as one row so limits survive hibernation and eviction.
291
301
  *
302
+ * **Retention:** the limiter calls `sweep` after each write, which deletes up to 32
303
+ * rows older than its horizon. The index on `ts` covers only rows with a
304
+ * non-negative `value`, so a reserved debt (never swept) adds nothing to the scan.
305
+ * The table holds the live keys plus the expired ones not yet swept. Limiters may
306
+ * share the table: each sweep deletes only the keys its own limiter owns, so a
307
+ * shorter-window limiter cannot evict a longer-window one's rows. A sweep reads
308
+ * candidates by `ts` before filtering by owner, so a long run of another limiter's
309
+ * expired rows can use up the window for a while.
310
+ *
292
311
  * **Atomicity:** `exec` is synchronous and the limiter does not yield between a
293
312
  * synchronous store's read and its write, so one consume's read, decision and
294
313
  * write run as a single JavaScript step. No other call on the same isolate can
@@ -326,6 +345,18 @@ interface RateLimitDatabaseReader {
326
345
  */
327
346
  interface RateLimitDatabase extends RateLimitDatabaseReader {
328
347
  delete: <T extends string>(id: Id<T>) => Promise<void>;
348
+ /**
349
+ * Used by `sweep` only: rows matching `where`, at most `limit`. Optional in the
350
+ * type because the public `DatabaseWriter` does not declare it; the framework's
351
+ * `ctx.db` implements it, and `sweep` throws if an adapter does not.
352
+ */
353
+ findMany?: (table: string, args: {
354
+ limit?: number;
355
+ orderBy?: Record<string, "asc" | "desc">[];
356
+ where?: Record<string, unknown>;
357
+ }) => Promise<{
358
+ page: Record<string, unknown>[];
359
+ }>;
329
360
  insert: <T extends string>(table: T, document: Record<string, unknown>, options?: {
330
361
  clientId?: string;
331
362
  }) => Promise<Id<T>>;
@@ -364,9 +395,29 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
364
395
  * ts: v.number(),
365
396
  * value: v.number(),
366
397
  * prev: v.optional(v.number()),
367
- * }).index("by_key", ["key"])
398
+ * })
399
+ * .index("by_key", ["key"])
400
+ * .index("by_ts", ["ts"])
368
401
  * ```
369
402
  *
403
+ * **Table shape change:** `by_ts` is new. The sweep finds expired rows through it.
404
+ * A table declared without it still works, but each sweep scans every row. Apps
405
+ * that already declared the table add the index. The sweep deletes at most 32
406
+ * expired rows per write; a reserved debt (negative `value`) is never deleted.
407
+ * The ORM index cannot be partial, so debts stay in it: many old debts make each
408
+ * sweep read past them. Reserves are rare, so this is accepted.
409
+ *
410
+ * **Ceiling:** the sweep deletes only a row whose `ts`, `value` and `prev` still match
411
+ * what it read, but the framework resolves that match and deletes in two steps.
412
+ * On a `.global()` table another shard can rewrite the row between them, so the
413
+ * row is deleted live and its bucket resets. Closing that needs a conditional
414
+ * delete on `ctx.db`, which does not exist yet. A shard-local table is not exposed:
415
+ * the Durable Object's input gate holds across the sweep's storage operations.
416
+ *
417
+ * **Soft delete:** the sweep deletes with `ctx.db.delete`, which has no hard-delete
418
+ * option. Declare the table without `.softDelete()`: a tombstoned row keeps its
419
+ * deterministic id, so the next consume for that key conflicts forever.
420
+ *
370
421
  * **Atomic against concurrent consumes.** `setIf` writes only if the row still
371
422
  * holds the value `get` returned: an existing row through `ctx.db.patchIf` with
372
423
  * that value as its `where`, a new row through an `insert` under an id derived
package/dist/index.d.ts CHANGED
@@ -94,16 +94,20 @@ interface RateLimitStore {
94
94
  setIf?: (storageKey: string, value: RateLimitValue, prior: RateLimitValue | undefined) => Promise<boolean>;
95
95
  /**
96
96
  * Optional eviction hint, called by the limiter after each successful write.
97
- * Examine up to `limit` entries from the store's oldest end and remove those
98
- * whose `ts` is at or before `olderThanTs`, except entries with a negative
99
- * `value`: a reserved debt is kept, whatever its age. `limit` caps entries
100
- * examined, not entries removed. Omit it when the store's rows must persist.
97
+ * Remove up to `limit` entries whose `ts` is at or before `olderThanTs`, except
98
+ * entries with a negative `value`: a reserved debt is kept, whatever its age.
99
+ * The memory store examines up to `limit` entries from its oldest end; the
100
+ * durable stores delete at most `limit` matching rows. A returned promise is
101
+ * awaited, so a store whose sweep is I/O (`createDbStore`) finishes it before the
102
+ * consume returns. The durable stores implement it so a table stays bounded
103
+ * without a scheduled job; see `createSqlStore` and `createDbStore` for the
104
+ * table shape.
101
105
  *
102
106
  * `owns` says whether a storage key belongs to the limiter asking for the
103
107
  * sweep. A store shared with another limiter must leave the other limiter's
104
108
  * keys alone: its horizon is not this one's. Absent means every key is owned.
105
109
  */
106
- sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => void;
110
+ sweep?: (olderThanTs: number, limit: number, owns?: (storageKey: string) => boolean) => Promise<void> | void;
107
111
  }
108
112
  /** Inputs to {@link evaluate}. */
109
113
  interface EvaluateOptions {
@@ -210,6 +214,12 @@ declare class RateLimiter<Names extends string = string> {
210
214
  * than admitted.
211
215
  */
212
216
  private decide;
217
+ /**
218
+ * Evict what this limiter's horizon has expired, after a write has landed. The
219
+ * write is already done, so awaiting here does not reopen the read-decide-write
220
+ * window. Only a promise-returning sweep (`createDbStore`) is awaited for I/O.
221
+ */
222
+ private sweep;
213
223
  private normalizeKey;
214
224
  private resolve;
215
225
  private run;
@@ -289,6 +299,15 @@ interface SqlStoreOptions {
289
299
  * SQLite-backed store for durable, per-DO rate-limit state. Persists each
290
300
  * `(name, key)` pair as one row so limits survive hibernation and eviction.
291
301
  *
302
+ * **Retention:** the limiter calls `sweep` after each write, which deletes up to 32
303
+ * rows older than its horizon. The index on `ts` covers only rows with a
304
+ * non-negative `value`, so a reserved debt (never swept) adds nothing to the scan.
305
+ * The table holds the live keys plus the expired ones not yet swept. Limiters may
306
+ * share the table: each sweep deletes only the keys its own limiter owns, so a
307
+ * shorter-window limiter cannot evict a longer-window one's rows. A sweep reads
308
+ * candidates by `ts` before filtering by owner, so a long run of another limiter's
309
+ * expired rows can use up the window for a while.
310
+ *
292
311
  * **Atomicity:** `exec` is synchronous and the limiter does not yield between a
293
312
  * synchronous store's read and its write, so one consume's read, decision and
294
313
  * write run as a single JavaScript step. No other call on the same isolate can
@@ -326,6 +345,18 @@ interface RateLimitDatabaseReader {
326
345
  */
327
346
  interface RateLimitDatabase extends RateLimitDatabaseReader {
328
347
  delete: <T extends string>(id: Id<T>) => Promise<void>;
348
+ /**
349
+ * Used by `sweep` only: rows matching `where`, at most `limit`. Optional in the
350
+ * type because the public `DatabaseWriter` does not declare it; the framework's
351
+ * `ctx.db` implements it, and `sweep` throws if an adapter does not.
352
+ */
353
+ findMany?: (table: string, args: {
354
+ limit?: number;
355
+ orderBy?: Record<string, "asc" | "desc">[];
356
+ where?: Record<string, unknown>;
357
+ }) => Promise<{
358
+ page: Record<string, unknown>[];
359
+ }>;
329
360
  insert: <T extends string>(table: T, document: Record<string, unknown>, options?: {
330
361
  clientId?: string;
331
362
  }) => Promise<Id<T>>;
@@ -364,9 +395,29 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
364
395
  * ts: v.number(),
365
396
  * value: v.number(),
366
397
  * prev: v.optional(v.number()),
367
- * }).index("by_key", ["key"])
398
+ * })
399
+ * .index("by_key", ["key"])
400
+ * .index("by_ts", ["ts"])
368
401
  * ```
369
402
  *
403
+ * **Table shape change:** `by_ts` is new. The sweep finds expired rows through it.
404
+ * A table declared without it still works, but each sweep scans every row. Apps
405
+ * that already declared the table add the index. The sweep deletes at most 32
406
+ * expired rows per write; a reserved debt (negative `value`) is never deleted.
407
+ * The ORM index cannot be partial, so debts stay in it: many old debts make each
408
+ * sweep read past them. Reserves are rare, so this is accepted.
409
+ *
410
+ * **Ceiling:** the sweep deletes only a row whose `ts`, `value` and `prev` still match
411
+ * what it read, but the framework resolves that match and deletes in two steps.
412
+ * On a `.global()` table another shard can rewrite the row between them, so the
413
+ * row is deleted live and its bucket resets. Closing that needs a conditional
414
+ * delete on `ctx.db`, which does not exist yet. A shard-local table is not exposed:
415
+ * the Durable Object's input gate holds across the sweep's storage operations.
416
+ *
417
+ * **Soft delete:** the sweep deletes with `ctx.db.delete`, which has no hard-delete
418
+ * option. Declare the table without `.softDelete()`: a tombstoned row keeps its
419
+ * deterministic id, so the next consume for that key conflicts forever.
420
+ *
370
421
  * **Atomic against concurrent consumes.** `setIf` writes only if the row still
371
422
  * holds the value `get` returned: an existing row through `ctx.db.patchIf` with
372
423
  * that value as its `where`, a new row through an `insert` under an id derived
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-BhJ1lMBZ.mjs";import{default as i}from"./packem_shared/dbRateLimit-BgxKsJmX.mjs";import{default as l}from"./packem_shared/RateLimitError-9Y5OLxpl.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-CWZur6XE.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-CFnDs9rj.mjs";import{RateLimiter as u}from"./packem_shared/RateLimiter-CCSZUABq.mjs";import{createDbStore as b,createMemoryStore as n,createReadOnlyDbStore as L,createSqlStore as s}from"./packem_shared/createDbStore-Dma2vpJG.mjs";import{tokenBudget as v}from"./packem_shared/tokenBudget-CvkodZ0R.mjs";const e="0.0.0";export{l as RateLimitError,u as RateLimiter,e as VERSION,o as availableAt,b as createDbStore,n as createMemoryStore,L as createReadOnlyDbStore,s as createSqlStore,i as dbRateLimit,a as evaluate,x as rateLimit,c as ratelimitPlugin,v as tokenBudget};
1
+ import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-BhJ1lMBZ.mjs";import{default as i}from"./packem_shared/dbRateLimit-DYnFEJdq.mjs";import{default as l}from"./packem_shared/RateLimitError-9Y5OLxpl.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-CWZur6XE.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-CFnDs9rj.mjs";import{RateLimiter as u}from"./packem_shared/RateLimiter-DUQPC6Cc.mjs";import{createDbStore as b,createMemoryStore as n,createReadOnlyDbStore as L,createSqlStore as s}from"./packem_shared/createDbStore-fSBhI7xO.mjs";import{tokenBudget as v}from"./packem_shared/tokenBudget-CvkodZ0R.mjs";const e="0.0.0";export{l as RateLimitError,u as RateLimiter,e as VERSION,o as availableAt,b as createDbStore,n as createMemoryStore,L as createReadOnlyDbStore,s as createSqlStore,i as dbRateLimit,a as evaluate,x as rateLimit,c as ratelimitPlugin,v as tokenBudget};
@@ -1 +1 @@
1
- import{LunoraError as h}from"@lunora/errors";import{retentionMs as p,availableAt as y,evaluate as v}from"./availableAt-BhJ1lMBZ.mjs";import f from"./RateLimitError-9Y5OLxpl.mjs";import{createMemoryStore as I}from"./createDbStore-Dma2vpJG.mjs";const b=/^[\d.:a-f]+$/iu,d=r=>{const t=r.indexOf("%"),e=t===-1?r:r.slice(0,t);if(!e.includes(":")||!b.test(e))return r;let s;try{s=new URL(`http://[${e}]/`).hostname.slice(1,-1)}catch{return r}const[i="",n]=s.split("::"),a=i===""?[]:i.split(":"),l=n===void 0||n===""?[]:n.split(":"),c=[...a,...Array.from({length:8-a.length-l.length},()=>"0"),...l].map(o=>Number.parseInt(o,16));if(c.slice(0,5).every(o=>o===0)&&c[5]===65535){const[o=0,m=0]=c.slice(6);return[o>>8,o&255,m>>8,m&255].join(".")}return`${c.slice(0,4).map(o=>o.toString(16)).join(":")}::/64`},u=(r,t)=>t===void 0?encodeURIComponent(r):`${encodeURIComponent(r)}:${encodeURIComponent(t)}`,N=16,w=32,g=r=>typeof r?.then=="function",L=r=>{let t=Number.NEGATIVE_INFINITY;return()=>(t=Math.max(t,r()),t)},R=r=>r?t=>d(r(t)):d,T="@lunora/ratelimit: new RateLimiter() was built with no explicit `store`, so it falls back to `createMemoryStore()` — an in-process Map. That Map is durable and correctly shared only for calls that land on the same Durable Object instance; it is NOT shared across `.shardBy(...)` shards or `.global()` replicas, and it resets when the DO instance is evicted/restarted. For a limit that must hold across any of those, pass a durable `store` (`createDbStore` or `createSqlStore`) instead. Pass `store: createMemoryStore()` explicitly once you've confirmed the in-memory default is correct here, to silence this warning.";class M{config;denyList;normalize;now;retention;store;owns;constructor(t){this.config=structuredClone(t.config);const e=new Set(Object.keys(this.config).map(s=>encodeURIComponent(s)));this.owns=s=>e.has(s.split(":")[0]??""),this.normalize=R(t.normalize),this.denyList=new Set([...t.denyList??[]].flatMap(s=>[s,this.normalize(s)])),this.now=L(t.now??Date.now),t.store===void 0&&console.warn(T),this.store=t.store??I(),this.retention=p(Object.values(this.config));for(const[s,i]of Object.entries(this.config)){if(Object.hasOwn(i,"shards"))throw new h("INTERNAL",`rate limit "${s}": \`shards\` is no longer supported — remove it; the limit now enforces its full \`rate\` (lower \`rate\` if you raised it to offset the split)`);if(!Number.isFinite(i.period)||i.period<=0)throw new h("INTERNAL",`rate limit "${s}": period must be a positive number`);if(!Number.isFinite(i.rate)||i.rate<=0)throw new h("INTERNAL",`rate limit "${s}": rate must be a positive number`);if(i.capacity!==void 0&&(!Number.isFinite(i.capacity)||i.capacity<0))throw new h("INTERNAL",`rate limit "${s}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const s=this.resolve(t),i=this.now(),n=u(t,this.normalizeKey(e.key)),a=y(s,await this.store.get(n),i);return{config:s,ts:a.ts,value:a.value}}async limit(t,e={}){return this.run(t,e,!0)}async reset(t,e={}){this.resolve(t),await Promise.resolve(this.store.delete(u(t,this.normalizeKey(e.key))))}async decide(t,e,s){for(let i=0;i<N;i+=1){const n=this.store.get(e),a=g(n)?await n:n,l=this.now(),{status:c,value:o}=v(t,a,{...s,now:l});if(o===void 0)return c;if(this.store.setIf===void 0)return await this.store.set(e,o),this.store.sweep?.(l-this.retention,w,this.owns),c;if(await this.store.setIf(e,o,a))return this.store.sweep?.(l-this.retention,w,this.owns),c}return{ok:!1,reason:"rate",retryAfter:0}}normalizeKey(t){return t===void 0?void 0:this.normalize(t)}resolve(t){const e=this.config[t];if(!e)throw new h("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,s){const i=this.resolve(t),n=this.normalizeKey(e.key);if(n!==void 0&&(this.denyList.has(n)||this.denyList.has(e.key))){const o={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new f(o);return o}const a=e.count??1;if(!Number.isInteger(a)||a<=0)throw new h("INTERNAL",`rate limit "${t}": count must be a positive integer`);const l=u(t,n),c=await this.decide(i,l,{consume:s,count:a,reserve:e.reserve??!1});if(!c.ok&&e.throws)throw new f(c);return c}}export{M as RateLimiter};
1
+ import{LunoraError as h}from"@lunora/errors";import{retentionMs as p,availableAt as y,evaluate as v}from"./availableAt-BhJ1lMBZ.mjs";import f from"./RateLimitError-9Y5OLxpl.mjs";import{createMemoryStore as I}from"./createDbStore-fSBhI7xO.mjs";const b=/^[\d.:a-f]+$/iu,w=r=>{const t=r.indexOf("%"),e=t===-1?r:r.slice(0,t);if(!e.includes(":")||!b.test(e))return r;let s;try{s=new URL(`http://[${e}]/`).hostname.slice(1,-1)}catch{return r}const[i="",n]=s.split("::"),a=i===""?[]:i.split(":"),l=n===void 0||n===""?[]:n.split(":"),c=[...a,...Array.from({length:8-a.length-l.length},()=>"0"),...l].map(o=>Number.parseInt(o,16));if(c.slice(0,5).every(o=>o===0)&&c[5]===65535){const[o=0,m=0]=c.slice(6);return[o>>8,o&255,m>>8,m&255].join(".")}return`${c.slice(0,4).map(o=>o.toString(16)).join(":")}::/64`},u=(r,t)=>t===void 0?encodeURIComponent(r):`${encodeURIComponent(r)}:${encodeURIComponent(t)}`,N=16,g=32,d=r=>typeof r?.then=="function",L=r=>{let t=Number.NEGATIVE_INFINITY;return()=>(t=Math.max(t,r()),t)},R=r=>r?t=>w(r(t)):w,T="@lunora/ratelimit: new RateLimiter() was built with no explicit `store`, so it falls back to `createMemoryStore()` — an in-process Map. That Map is durable and correctly shared only for calls that land on the same Durable Object instance; it is NOT shared across `.shardBy(...)` shards or `.global()` replicas, and it resets when the DO instance is evicted/restarted. For a limit that must hold across any of those, pass a durable `store` (`createDbStore` or `createSqlStore`) instead. Pass `store: createMemoryStore()` explicitly once you've confirmed the in-memory default is correct here, to silence this warning.";class M{config;denyList;normalize;now;retention;store;owns;constructor(t){this.config=structuredClone(t.config);const e=new Set(Object.keys(this.config).map(s=>encodeURIComponent(s)));this.owns=s=>e.has(s.split(":")[0]??""),this.normalize=R(t.normalize),this.denyList=new Set([...t.denyList??[]].flatMap(s=>[s,this.normalize(s)])),this.now=L(t.now??Date.now),t.store===void 0&&console.warn(T),this.store=t.store??I(),this.retention=p(Object.values(this.config));for(const[s,i]of Object.entries(this.config)){if(Object.hasOwn(i,"shards"))throw new h("INTERNAL",`rate limit "${s}": \`shards\` is no longer supported — remove it; the limit now enforces its full \`rate\` (lower \`rate\` if you raised it to offset the split)`);if(!Number.isFinite(i.period)||i.period<=0)throw new h("INTERNAL",`rate limit "${s}": period must be a positive number`);if(!Number.isFinite(i.rate)||i.rate<=0)throw new h("INTERNAL",`rate limit "${s}": rate must be a positive number`);if(i.capacity!==void 0&&(!Number.isFinite(i.capacity)||i.capacity<0))throw new h("INTERNAL",`rate limit "${s}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const s=this.resolve(t),i=this.now(),n=u(t,this.normalizeKey(e.key)),a=y(s,await this.store.get(n),i);return{config:s,ts:a.ts,value:a.value}}async limit(t,e={}){return this.run(t,e,!0)}async reset(t,e={}){this.resolve(t),await Promise.resolve(this.store.delete(u(t,this.normalizeKey(e.key))))}async decide(t,e,s){for(let i=0;i<N;i+=1){const n=this.store.get(e),a=d(n)?await n:n,l=this.now(),{status:c,value:o}=v(t,a,{...s,now:l});if(o===void 0)return c;if(this.store.setIf===void 0)return await this.store.set(e,o),await this.sweep(l),c;if(await this.store.setIf(e,o,a))return await this.sweep(l),c}return{ok:!1,reason:"rate",retryAfter:0}}async sweep(t){try{const e=this.store.sweep?.(t-this.retention,g,this.owns);d(e)&&await e}catch(e){console.warn("@lunora/ratelimit: the retention sweep failed; the consume still stands",e)}}normalizeKey(t){return t===void 0?void 0:this.normalize(t)}resolve(t){const e=this.config[t];if(!e)throw new h("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,s){const i=this.resolve(t),n=this.normalizeKey(e.key);if(n!==void 0&&(this.denyList.has(n)||this.denyList.has(e.key))){const o={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new f(o);return o}const a=e.count??1;if(!Number.isInteger(a)||a<=0)throw new h("INTERNAL",`rate limit "${t}": count must be a positive integer`);const l=u(t,n),c=await this.decide(i,l,{consume:s,count:a,reserve:e.reserve??!1});if(!c.ok&&e.throws)throw new f(c);return c}}export{M as RateLimiter};
@@ -0,0 +1 @@
1
+ import{LunoraError as b}from"@lunora/errors";const L=()=>{const a=new Map;return{delete:e=>{a.delete(e)},get:e=>a.get(e),set:(e,r)=>{a.delete(e),a.set(e,r)},sweep:(e,r,c)=>{let s=0;for(const[l,o]of a){if(s>=r)break;s+=1,a.delete(l),(o.value<0||o.ts>e||c?.(l)===!1)&&a.set(l,o)}}}},v=(a,e,...r)=>a.exec.call(a,e,...r).toArray(),O=a=>{const{sql:e}=a,r=a.table??"_lunora_rate_limits";return v(e,`CREATE TABLE IF NOT EXISTS "${r}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),v(e,`CREATE INDEX IF NOT EXISTS "${r}_ts" ON "${r}" (ts) WHERE value >= 0`),{delete:c=>{v(e,`DELETE FROM "${r}" WHERE k = ?`,c)},sweep:(c,s,l)=>{const o=v(e,`SELECT k FROM "${r}" WHERE ts <= ? AND value >= 0 ORDER BY ts LIMIT ?`,c,s);for(const{k:f}of o)l?.(f)!==!1&&v(e,`DELETE FROM "${r}" WHERE k = ?`,f)},get:c=>{const l=v(e,`SELECT value, ts, prev FROM "${r}" WHERE k = ?`,c)[0];if(!l)return;const o={ts:l.ts,value:l.value};return l.prev!==null&&(o.prev=l.prev),o},set:(c,s)=>{v(e,`INSERT INTO "${r}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,c,s.value,s.ts,s.prev??null)}}},T=a=>a.toString(16).padStart(2,"0"),h=async(a,e)=>{const c=[...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(`${a}\0${e}`))).subarray(0,16)];c[6]=128+c[6]%16,c[8]=128+c[8]%64;const s=c.map(l=>T(l)).join("");return`${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20)}`},I=a=>a?.code==="CONFLICT",R=a=>{const{db:e}=a,r=a.table??"rateLimits",c=a.keyField??"key",s=new Map;let l=!1;const o=async t=>{const n=await e.findFirst(r,{where:{[c]:t}});return s.set(t,n?n._id:void 0),n},f=async t=>(s.has(t)||await o(t),s.get(t)),E=(t,n)=>{const i={[c]:t,ts:n.ts,value:n.value};return n.prev!==void 0&&(i.prev=n.prev),i},p=async(t,n)=>{try{return s.set(t,await e.insert(r,n,{clientId:await h(r,t)})),!0}catch(i){if(I(i))return s.delete(t),!1;throw i}};return{delete:async t=>{const n=await f(t);n!==void 0&&await e.delete(n),s.delete(t)},get:async t=>{const n=await o(t);if(!n)return;const i={ts:n.ts,value:n.value};return n.prev!==null&&n.prev!==void 0&&(i.prev=n.prev),i},set:async(t,n)=>{const i=E(t,n);let u=await f(t);if(u===void 0){if(await p(t,i))return;await o(t),u=s.get(t)}u!==void 0&&await e.patchIf(r,u,{patch:i,where:{}})},setIf:async(t,n,i)=>{const u=E(t,n);if(i===void 0)return p(t,u);const w=await f(t);if(w===void 0)return!1;const{applied:d}=await e.patchIf(r,w,{patch:u,where:{prev:i.prev??null,ts:i.ts,value:i.value}});return d||s.delete(t),d},sweep:async(t,n,i)=>{if(e.findMany===void 0){l||(l=!0,console.warn(`@lunora/ratelimit: table "${r}" is not swept: the adapter has no findMany. Pass the framework's ctx.db.`));return}const{page:u}=await e.findMany(r,{limit:n,orderBy:[{ts:"asc"}],where:{ts:{lte:t},value:{gte:0}}}),w=u.filter(d=>i?.(String(d[c]))!==!1);await Promise.all(w.map(async d=>{await e.findFirst(r,{where:{_id:d._id,prev:d.prev??null,ts:d.ts,value:d.value}})!==null&&await e.delete(d._id)}))}}},y=a=>{const e=c=>{throw new b("INTERNAL",`@lunora/ratelimit: \`${c}\` needs a writable \`ctx.db\`, but this store was created with \`createReadOnlyDbStore\` (a query context). Use \`createDbStore\` from a mutation or action; a query can only call \`getValue\`/\`check\`.`)},r=R({...a,db:{delete:()=>e("delete"),findFirst:a.db.findFirst.bind(a.db),insert:()=>e("insert"),patchIf:()=>e("patchIf")}});return{delete:()=>e("reset"),get:r.get,set:()=>e("limit"),setIf:()=>e("limit")}};export{R as createDbStore,L as createMemoryStore,y as createReadOnlyDbStore,O as createSqlStore};
@@ -1 +1 @@
1
- import{rateLimit as o}from"./rateLimit-CWZur6XE.mjs";import{RateLimiter as m}from"./RateLimiter-CCSZUABq.mjs";import{createDbStore as i}from"./createDbStore-Dma2vpJG.mjs";const f=(e,r,t={})=>o(a=>new m({config:e,store:i({db:a.db,...t.store})}),r,t);export{f as default};
1
+ import{rateLimit as o}from"./rateLimit-CWZur6XE.mjs";import{RateLimiter as m}from"./RateLimiter-DUQPC6Cc.mjs";import{createDbStore as i}from"./createDbStore-fSBhI7xO.mjs";const f=(e,r,t={})=>o(a=>new m({config:e,store:i({db:a.db,...t.store})}),r,t);export{f as default};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/ratelimit",
3
- "version": "1.0.0-alpha.140",
3
+ "version": "1.0.0-alpha.142",
4
4
  "description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, pluggable stores, and procedure middleware",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
1
- import{LunoraError as E}from"@lunora/errors";const m=()=>{const r=new Map;return{delete:e=>{r.delete(e)},get:e=>r.get(e),set:(e,c)=>{r.delete(e),r.set(e,c)},sweep:(e,c,s)=>{let n=0;for(const[l,o]of r){if(n>=c)break;n+=1,r.delete(l),(o.value<0||o.ts>e||s?.(l)===!1)&&r.set(l,o)}}}},u=(r,e,...c)=>r.exec.call(r,e,...c).toArray(),L=r=>{const{sql:e}=r,c=r.table??"_lunora_rate_limits";return u(e,`CREATE TABLE IF NOT EXISTS "${c}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),{delete:s=>{u(e,`DELETE FROM "${c}" WHERE k = ?`,s)},get:s=>{const l=u(e,`SELECT value, ts, prev FROM "${c}" WHERE k = ?`,s)[0];if(!l)return;const o={ts:l.ts,value:l.value};return l.prev!==null&&(o.prev=l.prev),o},set:(s,n)=>{u(e,`INSERT INTO "${c}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,s,n.value,n.ts,n.prev??null)}}},b=r=>r.toString(16).padStart(2,"0"),S=async(r,e)=>{const s=[...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(`${r}\0${e}`))).subarray(0,16)];s[6]=128+s[6]%16,s[8]=128+s[8]%64;const n=s.map(l=>b(l)).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`},T=r=>r?.code==="CONFLICT",h=r=>{const{db:e}=r,c=r.table??"rateLimits",s=r.keyField??"key",n=new Map,l=async t=>{const a=await e.findFirst(c,{where:{[s]:t}});return n.set(t,a?a._id:void 0),a},o=async t=>(n.has(t)||await l(t),n.get(t)),v=(t,a)=>{const i={[s]:t,ts:a.ts,value:a.value};return a.prev!==void 0&&(i.prev=a.prev),i},f=async(t,a)=>{try{return n.set(t,await e.insert(c,a,{clientId:await S(c,t)})),!0}catch(i){if(T(i))return n.delete(t),!1;throw i}};return{delete:async t=>{const a=await o(t);a!==void 0&&await e.delete(a),n.delete(t)},get:async t=>{const a=await l(t);if(!a)return;const i={ts:a.ts,value:a.value};return a.prev!==null&&a.prev!==void 0&&(i.prev=a.prev),i},set:async(t,a)=>{const i=v(t,a);let d=await o(t);if(d===void 0){if(await f(t,i))return;await l(t),d=n.get(t)}d!==void 0&&await e.patchIf(c,d,{patch:i,where:{}})},setIf:async(t,a,i)=>{const d=v(t,a);if(i===void 0)return f(t,d);const w=await o(t);if(w===void 0)return!1;const{applied:p}=await e.patchIf(c,w,{patch:d,where:{prev:i.prev??null,ts:i.ts,value:i.value}});return p||n.delete(t),p}}},R=r=>{const e=s=>{throw new E("INTERNAL",`@lunora/ratelimit: \`${s}\` needs a writable \`ctx.db\`, but this store was created with \`createReadOnlyDbStore\` (a query context). Use \`createDbStore\` from a mutation or action; a query can only call \`getValue\`/\`check\`.`)},c=h({...r,db:{delete:()=>e("delete"),findFirst:r.db.findFirst.bind(r.db),insert:()=>e("insert"),patchIf:()=>e("patchIf")}});return{delete:()=>e("reset"),get:c.get,set:()=>e("limit"),setIf:()=>e("limit")}};export{h as createDbStore,m as createMemoryStore,R as createReadOnlyDbStore,L as createSqlStore};