{
  "$schema": "https://hyperjs.ai/schema/registry-item.json",
  "name": "idempotency",
  "version": "0.1.0",
  "description": "Idempotency-Key middleware — one-shot result caching for mutating requests.",
  "readme": "# @hyper/idempotency\n\n`Idempotency-Key` middleware — one-shot result caching for mutating requests.\n\n## Install\n\nComponents are installed as source into your repo, not pulled from npm:\n\n```bash\nbunx hyper add idempotency\n```\n\nWires the alias `@hyper/idempotency` to `src/hyper/idempotency/` (configurable in `hyper.config.json`). See [hyperjs.ai](https://hyperjs.ai) for the full registry.\n\n## Usage\n\n```ts\nimport { Hyper } from \"@hyper/core\"\nimport { idempotency } from \"@hyper/idempotency\"\n\nexport default new Hyper()\n  .use(idempotency())\n  .listen(3000)\n```\n\n## Docs\n\nSee the [main README](../../README.md) and [docs/](../../docs) for guides and integration recipes.\n\n## License\n\nMIT\n",
  "registryDeps": [
    "core"
  ],
  "peerDeps": {},
  "optionalPeerDeps": {},
  "files": [
    {
      "path": "idempotency/index.ts",
      "contents": "/**\n * @hyper/idempotency — Idempotency-Key middleware.\n *\n * RFC-aligned behavior:\n *   - If request has `Idempotency-Key`, we hash (key + method + path + body)\n *     and cache the response for `ttlMs`.\n *   - Replays within the TTL return the cached response (with\n *     `Idempotent-Replayed: true` header).\n *   - Concurrent requests for the same key serialize via a short lock.\n *\n * This keeps consumers safe from retried PUT/POSTs at the edge.\n */\n\nimport { type Middleware, coerce } from \"@hyper/core\"\nimport { type IdempotencyStore, memoryStore } from \"./store.ts\"\n\nexport { memoryStore } from \"./store.ts\"\nexport type { CachedResponse, IdempotencyStore } from \"./store.ts\"\n\nexport interface IdempotencyConfig {\n  readonly store?: IdempotencyStore\n  /** Default: 24h. */\n  readonly ttlMs?: number\n  /** Default: [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]. */\n  readonly methods?: readonly string[]\n}\n\nconst DEFAULT_METHODS = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"])\nconst DAY = 24 * 60 * 60 * 1000\n\nexport function idempotency(config: IdempotencyConfig = {}): Middleware {\n  const store = config.store ?? memoryStore()\n  const ttl = config.ttlMs ?? DAY\n  const methods = new Set(config.methods ?? [...DEFAULT_METHODS])\n\n  return async ({ req, path, next }) => {\n    if (!methods.has(req.method)) return next()\n    const key = req.headers.get(\"idempotency-key\")\n    if (!key) return next()\n    const cacheKey = await hash(`${key}|${req.method}|${path}|${await peekBody(req)}`)\n\n    const cached = await store.get(cacheKey)\n    if (cached) return replay(cached)\n\n    const locked = await store.lock(cacheKey, 30_000)\n    if (!locked) {\n      // Another in-flight request holds this key — conservative: 409.\n      return new Response(JSON.stringify({ error: \"idempotency_in_flight\", idempotencyKey: key }), {\n        status: 409,\n        headers: { \"content-type\": \"application/json\" },\n      })\n    }\n\n    try {\n      const out = await next()\n      const res = out instanceof Response ? out : coerce(out)\n      const body = await res.clone().text()\n      const headers: Record<string, string> = {}\n      for (const [k, v] of res.headers) headers[k] = v\n      await store.set(cacheKey, { status: res.status, headers, body, createdAt: Date.now() }, ttl)\n      return res\n    } finally {\n      await store.unlock(cacheKey)\n    }\n  }\n}\n\nfunction replay(c: {\n  readonly status: number\n  readonly headers: Record<string, string>\n  readonly body: string\n}) {\n  const h = new Headers(c.headers)\n  h.set(\"idempotent-replayed\", \"true\")\n  return new Response(c.body, { status: c.status, headers: h })\n}\n\nasync function peekBody(req: Request): Promise<string> {\n  if (!req.body) return \"\"\n  // Hash-only peek — we deliberately consume into a cloned Request so\n  // downstream handlers still see the original stream.\n  try {\n    return await req.clone().text()\n  } catch {\n    return \"\"\n  }\n}\n\nasync function hash(s: string): Promise<string> {\n  const buf = new TextEncoder().encode(s)\n  const digest = await crypto.subtle.digest(\"SHA-256\", buf)\n  return Array.from(new Uint8Array(digest))\n    .map((b) => b.toString(16).padStart(2, \"0\"))\n    .join(\"\")\n}\n",
      "sha256": "2dacbe196c26c16ec748ac6b07396257e89ac5840f98506480f5a10acc2a5380"
    },
    {
      "path": "idempotency/sqlite.ts",
      "contents": "/**\n * bun:sqlite-backed IdempotencyStore — persistent across process restarts\n * and suitable for single-node production. For multi-node, use Redis.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport type { CachedResponse, IdempotencyStore } from \"./store.ts\"\n\nexport interface SqliteIdempotencyOptions {\n  readonly path?: string\n}\n\nexport function sqliteIdempotency(opts: SqliteIdempotencyOptions = {}): IdempotencyStore & {\n  readonly sweep: () => void\n  readonly close: () => void\n} {\n  const path = opts.path ?? \"./.hyper/idempotency.sqlite\"\n  const db = new Database(path, { create: true })\n  db.exec(\"PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;\")\n  db.exec(`\n    CREATE TABLE IF NOT EXISTS hyper_idempotency (\n      k TEXT PRIMARY KEY,\n      status INTEGER NOT NULL,\n      headers TEXT NOT NULL,\n      body TEXT NOT NULL,\n      created_at INTEGER NOT NULL,\n      expires_at INTEGER NOT NULL\n    );\n    CREATE TABLE IF NOT EXISTS hyper_idempotency_locks (\n      k TEXT PRIMARY KEY,\n      expires_at INTEGER NOT NULL\n    );\n  `)\n  const now = () => Date.now()\n  const stmtGet = db.prepare(\n    \"SELECT status, headers, body, created_at AS createdAt, expires_at AS expiresAt FROM hyper_idempotency WHERE k = ?\",\n  )\n  const stmtSet = db.prepare(\n    \"INSERT OR REPLACE INTO hyper_idempotency (k, status, headers, body, created_at, expires_at) VALUES (?,?,?,?,?,?)\",\n  )\n  const stmtLockGet = db.prepare(\n    \"SELECT expires_at AS expiresAt FROM hyper_idempotency_locks WHERE k = ?\",\n  )\n  const stmtLockSet = db.prepare(\n    \"INSERT OR REPLACE INTO hyper_idempotency_locks (k, expires_at) VALUES (?, ?)\",\n  )\n  const stmtLockDel = db.prepare(\"DELETE FROM hyper_idempotency_locks WHERE k = ?\")\n  const stmtSweep = db.prepare(\"DELETE FROM hyper_idempotency WHERE expires_at < ?\")\n  const stmtSweepLocks = db.prepare(\"DELETE FROM hyper_idempotency_locks WHERE expires_at < ?\")\n\n  const sweep = () => {\n    const t = now()\n    stmtSweep.run(t)\n    stmtSweepLocks.run(t)\n  }\n\n  return {\n    async get(key) {\n      const row = stmtGet.get(key) as\n        | { status: number; headers: string; body: string; createdAt: number; expiresAt: number }\n        | undefined\n      if (!row) return undefined\n      if (row.expiresAt < now()) return undefined\n      return {\n        status: row.status,\n        headers: JSON.parse(row.headers) as Record<string, string>,\n        body: row.body,\n        createdAt: row.createdAt,\n      } satisfies CachedResponse\n    },\n    async set(key, value, ttlMs) {\n      stmtSet.run(\n        key,\n        value.status,\n        JSON.stringify(value.headers),\n        value.body,\n        value.createdAt,\n        now() + ttlMs,\n      )\n    },\n    async lock(key, ttlMs) {\n      const row = stmtLockGet.get(key) as { expiresAt: number } | undefined\n      if (row && row.expiresAt >= now()) return false\n      stmtLockSet.run(key, now() + ttlMs)\n      return true\n    },\n    async unlock(key) {\n      stmtLockDel.run(key)\n    },\n    sweep,\n    close: () => db.close(),\n  }\n}\n",
      "sha256": "b87abeaf293f180269aecdde1e1bebae166fe2ec806eee1cd8e6fb4fe25cd1e7"
    },
    {
      "path": "idempotency/store.ts",
      "contents": "/**\n * IdempotencyStore — pluggable cache backend.\n *\n * We ship an in-memory TTL store by default. Production deployments\n * bind a Redis/KeyDB adapter via `.store(...)`.\n */\n\nexport interface CachedResponse {\n  readonly status: number\n  readonly headers: Record<string, string>\n  readonly body: string\n  readonly createdAt: number\n}\n\nexport interface IdempotencyStore {\n  get(key: string): Promise<CachedResponse | undefined>\n  set(key: string, value: CachedResponse, ttlMs: number): Promise<void>\n  /** Returns true if the key was *newly* locked. Used for request-in-flight races. */\n  lock(key: string, ttlMs: number): Promise<boolean>\n  unlock(key: string): Promise<void>\n}\n\nexport function memoryStore(): IdempotencyStore {\n  const data = new Map<string, { value: CachedResponse; expires: number }>()\n  const locks = new Map<string, number>()\n  const now = () => Date.now()\n  return {\n    async get(key) {\n      const v = data.get(key)\n      if (!v) return undefined\n      if (v.expires < now()) {\n        data.delete(key)\n        return undefined\n      }\n      return v.value\n    },\n    async set(key, value, ttlMs) {\n      data.set(key, { value, expires: now() + ttlMs })\n    },\n    async lock(key, ttlMs) {\n      const existing = locks.get(key)\n      if (existing && existing >= now()) return false\n      locks.set(key, now() + ttlMs)\n      return true\n    },\n    async unlock(key) {\n      locks.delete(key)\n    },\n  }\n}\n",
      "sha256": "acc224c4c2d63185e1d1762e5da222f8c094ad22d2e12d1b3a1f0d5ca3408e75"
    }
  ],
  "subpaths": {}
}