import { S as Store, C as Clock, T as Transform } from './types-DKirIBQt.js'; /** * Cloudflare **Durable Objects** store — the correct atomic backend for rate limiting on Cloudflare. * * A Durable Object is a single-threaded actor with strongly-consistent transactional storage, which * makes it the *right* Cloudflare primitive for a limiter: unlike Workers KV (eventually consistent, * no atomic compare-and-set — it cannot honor the {@link Store} contract and would silently * over-admit), a DO can run an exact read-modify-write. This store wraps the limiter's **existing pure * JS transform** (the same code every other backend runs — there is no DO-specific algorithm to keep * in sync) inside {@link DurableObjectStateLike.blockConcurrencyWhile}, which serializes the section * against every other handler in the object. So `apply` is atomic with **no optimistic-retry loop**: * N concurrent increments land exactly N, like Redis `INCR`. * * **Where it runs.** Construct it *inside* your Durable Object, from the object's `state`: * * ```ts * import { rateLimit, gcra } from "throttlekit"; * import { DurableObjectStore } from "throttlekit/cloudflare"; * * export class RateLimiter { * private limiter; * constructor(state: DurableObjectState) { * this.limiter = rateLimit({ * strategy: gcra({ limit: 100, periodMs: 60_000, burst: 20 }), * store: new DurableObjectStore(state), * }); * } * async fetch(req: Request) { * const { pathname } = new URL(req.url); * const d = await this.limiter.check(pathname.slice(1) || "default"); * return Response.json(d, { status: d.allowed ? 200 : 429 }); * } * } * ``` * * **Sharding.** Each DO instance is a serialization point. For independent throughput per identity, * route each rate-limit key to its **own** DO via `env.NS.idFromName(key)` (one key per object). To * share one global budget across a region, route a bounded key set through a single object — every * apply then serializes there, which is exactly what makes the budget exact, at that object's * throughput ceiling. * * **Expiry** is lazy: an entry whose stored expiry has passed reads as absent (the next apply starts * fresh), mirroring Redis/Postgres. Every built-in strategy is idempotent w.r.t. stale state, so a * late physical delete never changes a decision. To reclaim storage proactively, schedule a DO alarm * that deletes expired keys; lazy expiry already keeps decisions correct without one. * * **State** is stored as the same JSON text the Redis optimistic-concurrency and Postgres paths write, * so a value round-trips as the exact IEEE-754 double and decisions stay bit-identical across backends. */ interface DurableObjectStorageLike { /** Read a stored value (strongly consistent within the object). `undefined` when absent. */ get(key: string): Promise; /** Write a value. */ put(key: string, value: T): Promise; /** Delete a key; resolves `true` if it existed. */ delete(key: string): Promise; } /** * The minimal slice of a Cloudflare `DurableObjectState` ThrottleKit needs — its transactional * `storage` and `blockConcurrencyWhile`. A real `DurableObjectState` satisfies this structurally, so * you pass `state` directly; no `@cloudflare/workers-types` dependency is required. */ interface DurableObjectStateLike { /** The object's transactional key-value storage. */ storage: DurableObjectStorageLike; /** * Run `fn` while blocking delivery of any other event to this object until it settles — i.e. a * serialized critical section. This is what makes the read-modify-write atomic. */ blockConcurrencyWhile(fn: () => Promise): Promise; } interface DurableObjectStoreOptions { /** Storage key namespace, prefixed as `prefix:key`. */ prefix?: string; /** * Time source for lazy expiry. Defaults to {@link systemClock}; inject a `ManualClock` to drive * expiry deterministically in tests. */ clock?: Clock; } /** Distributed store backed by a single Cloudflare Durable Object. See the file-level docs. */ declare class DurableObjectStore implements Store { #private; constructor(state: DurableObjectStateLike, options?: DurableObjectStoreOptions); apply(key: string, transform: Transform): Promise; reset(key: string): Promise; } /** * The minimal slice of a Cloudflare **D1** database binding ThrottleKit needs. The `D1Database` your * Worker receives in `env` satisfies this structurally, so you pass it directly — no * `@cloudflare/workers-types` dependency. Any compatible binding (same method shapes) works too. */ interface D1Like { /** Build a prepared statement for one SQL statement (D1 is single-statement per prepare). */ prepare(query: string): D1PreparedStatementLike; } /** A D1 prepared statement: bind positional `?` params, then read one row or run the write. */ interface D1PreparedStatementLike { /** Bind positional parameters, returning a bound statement (D1 returns a fresh statement). */ bind(...values: unknown[]): D1PreparedStatementLike; /** Return the first row (or `null`) of a query. */ first>(): Promise; /** Execute a write and report how many rows changed via {@link D1ResultLike.meta}. */ run(): Promise; } /** The slice of a D1 run-result ThrottleKit reads: just the changed-row count from `meta`. */ interface D1ResultLike { /** Number of rows the statement changed — `1` means our conditional write committed. */ meta?: { changes?: number; }; } interface D1StoreOptions { /** A Cloudflare `D1Database` binding (or compatible). ThrottleKit never closes a binding it is given. */ db: D1Like; /** * Unquoted table identifier holding the limiter state. Validated against `^[A-Za-z_][A-Za-z0-9_]*$` * since identifiers cannot be parameterized. Default `"throttlekit"`. */ table?: string; /** Storage key namespace, prefixed as `prefix:key`. */ prefix?: string; /** * Create the table and its expiry index on first use. Default `true`. Set `false` when you manage * the schema via wrangler D1 migrations (see {@link D1Store} docs for the DDL). */ autoCreate?: boolean; /** * Bounded retries for the optimistic-concurrency compare-and-set. Default `16`. In-process applies * to one key are coalesced (see {@link D1Store}), so retries are only ever spent on genuine * *cross-isolate* races; `16` tolerates heavy cross-isolate contention on a single hot key. */ maxRetries?: number; /** * Time source for lazy expiry. Expired rows are filtered on read and reclaimed by {@link D1Store.sweep}, * so this is the clock that decides "expired". Defaults to {@link systemClock}; inject a `ManualClock` * to drive expiry deterministically in tests. */ clock?: Clock; } /** * Distributed store backed by **Cloudflare D1** (edge SQLite) — the right SQL backend for limiting on * Workers when you are not using a Durable Object. * * Unlike Postgres (transaction-scoped advisory lock) or a Durable Object (single-threaded actor), D1 * exposes neither a per-key lock nor an interactive transaction across `await` points, so the atomic * read-modify-write {@link Store.apply} demands is built from **optimistic concurrency** with a * version compare-and-set: * * ```text * SELECT state, expires_at, version WHERE key = ? -- read; lazy-expire in JS * -- the same pure code every backend runs * UPDATE ... SET version = version + 1 WHERE key = ? AND version = ? -- commit iff unchanged * -- (or INSERT ... ON CONFLICT DO NOTHING on first touch); changes = 0 ⇒ lost the race ⇒ retry * ``` * * The version check makes the write conditional: if another isolate wrote between our read and our * write, the `WHERE version = ?` matches nothing (`changes = 0`) and we re-read and retry. So N * concurrent increments across the fleet land exactly N — like Redis `INCR` — without ever holding a * lock. An expired row is overwritten in place by that same CAS (its stale version still matches what * we read), so expiry needs no separate delete on the hot path. * * **In-process coalescing.** D1 has no Lua/atomic-command path, so *every* apply takes the CAS loop — * and a hot key hammered from one isolate would otherwise CAS-contend with itself and burn retries * (and D1 bills per row read/written). So applies to the same key *from this isolate* are serialized * behind a per-key promise chain: each runs a single clean version bump, zero wasted retries. The CAS * still guarantees correctness across *other* isolates; coalescing just collapses self-contention. The * chain entry is dropped once it drains, so the lock map stays bounded by in-flight keys. * * **Expiry** is lazy: a row past its `expires_at` reads as absent (the next apply starts fresh), * keyed off this store's {@link Clock} exactly like the Redis/Postgres backends. Workers are * ephemeral, so there is no background sweep timer; call {@link D1Store.sweep} from a * [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) to reclaim * space. Lazy expiry already keeps every read correct without one. * * **State** is the same JSON text the Redis optimistic-concurrency and Postgres paths write, so a * value round-trips as the exact IEEE-754 double and decisions stay bit-identical across backends. * * **Schema** (auto-created unless {@link D1StoreOptions.autoCreate} is `false`): * ```sql * CREATE TABLE IF NOT EXISTS throttlekit ( * key TEXT PRIMARY KEY, state TEXT NOT NULL, expires_at INTEGER NOT NULL, version INTEGER NOT NULL); * CREATE INDEX IF NOT EXISTS throttlekit_expires_idx ON throttlekit (expires_at); * ``` * * Async-only: there is no `applySync`, so `limiter.checkSync` throws (use `await limiter.check`). * * @example * ```ts * import { rateLimit, gcra } from "throttlekit"; * import { D1Store } from "throttlekit/cloudflare"; * * export default { * async fetch(req: Request, env: { DB: D1Database }) { * const limiter = rateLimit({ * strategy: gcra({ limit: 100, periodMs: 60_000, burst: 20 }), * store: new D1Store({ db: env.DB }), * }); * const d = await limiter.check(new URL(req.url).pathname); * return Response.json(d, { status: d.allowed ? 200 : 429 }); * }, * }; * ``` */ declare class D1Store implements Store { #private; constructor(options: D1StoreOptions); apply(key: string, transform: Transform): Promise; reset(key: string): Promise; /** * Delete every row already past its expiry, returning how many were reclaimed. Lazy expiry keeps * reads correct without this; call it from a Cron Trigger only to reclaim storage. Best-effort — * a failure just delays reclamation. */ sweep(): Promise; } /** * The slice of a Cloudflare **Workers KV** namespace ThrottleKit uses. The real `KVNamespace` * satisfies it structurally — no `@cloudflare/workers-types` dependency. */ interface KVNamespaceLike { /** Read a key's text value (`null` if absent). */ get(key: string): Promise; /** Write a key; `expirationTtl` is seconds (Cloudflare enforces a 60s minimum). */ put(key: string, value: string, options?: { expirationTtl?: number; }): Promise; /** Delete a key. */ delete(key: string): Promise; } interface KVStoreOptions { /** The bound KV namespace (e.g. `env.RATELIMIT`). */ kv: KVNamespaceLike; /** Key namespace, so one KV can back many limiters. */ prefix?: string; /** Injected clock (epoch-ms). Defaults to the system clock. */ clock?: Clock; } /** * **Best-effort, approximate** rate-limit store on Cloudflare Workers KV. * * ⚠️ Unlike every other ThrottleKit store, this one is **NOT exact**. Workers KV is eventually * consistent and offers **no atomic compare-and-set**, so concurrent checks on the same key * read-modify-write over each other (lost updates) and a write may not be visible to a read on * another edge location for some seconds. Both effects mean it can **over-admit** under load. It is * therefore intentionally *not* run through the atomic store-conformance suite, and it does not honor * the strict `Store` guarantee the limiter normally relies on. * * Use it only where occasional over-admission is acceptable — coarse, cheap edge protection where you * have no Durable Object or D1 binding. **For correctness on Cloudflare, prefer `DurableObjectStore` * (single-threaded, exact) or `D1Store` (version-CAS, exact).** See the Distributed page. * * Notes: * - **Async only** (`checkSync` throws): every check is a network read + write. * - **Sub-minute windows are coarse:** KV's `expirationTtl` floor is 60s, so a key physically lingers * up to a minute. A *logical* expiry is stored alongside the state and enforced on read, so the * limiter's own window math stays correct — but cleanup of idle keys is no faster than 60s. */ declare class KVStore implements Store { #private; constructor(options: KVStoreOptions); apply(key: string, transform: Transform): Promise; reset(key: string): Promise; } export { type D1Like, type D1PreparedStatementLike, type D1ResultLike, D1Store, type D1StoreOptions, type DurableObjectStateLike, type DurableObjectStorageLike, DurableObjectStore, type DurableObjectStoreOptions, type KVNamespaceLike, KVStore, type KVStoreOptions };