patx/mongokv

<head>
  <meta charset="UTF-8">
  <title>mongoKV – Tiny Redis-style Key-Value Store for MongoDB (Python)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  
  <!-- SEO Meta Tags -->
  <meta name="description" content="mongoKV is a tiny async/sync key-value store wrapper for MongoDB using PyMongo. One collection per instance, works synchronously and asynchronously with the same API.">
  <meta name="keywords" content="mongodb, key-value store, pymongo, python, async, sync, database, nosql, kv store">
  <meta name="author" content="Harrison">
  
  <!-- Open Graph / Facebook -->
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://patx.github.io/mongokv/">
  <meta property="og:title" content="mongoKV – Tiny Key-Value Store for MongoDB">
  <meta property="og:description" content="Async/sync key-value store on top of PyMongo. One collection per instance, one document per key. Intentionally small, stable, and boring.">
  <meta property="og:image" content="https://raw.githubusercontent.com/patx/mongokv/refs/heads/main/docs/logo.png">
  
  <!-- Twitter Card -->
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:url" content="https://patx.github.io/mongokv/">
  <meta name="twitter:title" content="mongoKV – Tiny Key-Value Store for MongoDB">
  <meta name="twitter:description" content="Async/sync key-value store on top of PyMongo. One collection per instance, one document per key.">
  <meta name="twitter:image" content="https://raw.githubusercontent.com/patx/mongokv/refs/heads/main/docs/logo.png">
  <meta name="twitter:creator" content="@harrisonerd">
  
  <!-- Favicon (using your logo) -->
  <link rel="icon" type="image/png" href="https://raw.githubusercontent.com/patx/mongokv/refs/heads/main/docs/logo.png">
  <link rel="apple-touch-icon" href="https://raw.githubusercontent.com/patx/mongokv/refs/heads/main/docs/logo.png">
  
  <!-- Canonical URL -->
  <link rel="canonical" href="https://patx.github.io/mongokv/">
  
  <!-- Theme Color -->
  <meta name="theme-color" content="#24663c">

  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">

  <!-- highlight.js -->
  <link rel="stylesheet"
        href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css">
  <script defer
          src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/highlight.min.js"></script>
  <script defer
          src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/languages/python.min.js"></script>
  <script defer
          src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/languages/bash.min.js"></script>
  <script defer
          src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/languages/json.min.js"></script>

  <script defer>
    document.addEventListener("DOMContentLoaded", () => {
      document.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));

      const nav = document.getElementById("topnav");
      const onScroll = () => nav.classList.toggle("scrolled", window.scrollY > 2);
      onScroll();
      window.addEventListener("scroll", onScroll, { passive: true });
    });
  </script>

  <style>
    :root {
      --bg: #f5f5f5;
      --fg: #111827;
      --muted: #6b7280;
      --accent: #24663c;
      --border: #e5e7eb;
      --code-bg: #c4cfc6;
      --code-fg: black;
      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }

    html { scroll-behavior: smooth; }

    body {
      margin: 0;
      padding: 0;
      font-family: var(--sans);
      background: var(--bg);
      color: var(--fg);
      line-height: 1.6;
    }

    .page {
      max-width: 840px;
      margin: 0 auto;
      padding: 32px 16px 64px;
    }

    header {
      margin-bottom: 24px;
      padding-bottom: 16px;
      border-bottom: 1px solid var(--border);
    }

    h1, h2, h3, h4 {
      font-weight: 700;
      letter-spacing: -0.02em;
    }

    h1 {
      font-size: 4rem;
      margin: 0 0 4px;
    }

    .logo {
      font-family: 'Pacifico', cursive;
      color: var(--accent);
      letter-spacing: 3px;
    }

    .subtitle {
      color: var(--muted);
      margin: 0 0 12px;
    }

    .pronounce {
      color: var(--muted);
      font-size: 18px;
      font-weight: 400;
      white-space: nowrap;
    }

    a { color: var(--accent); text-decoration: none; }
    a:hover { text-decoration: underline; }

    p { margin: 0 0 1rem; }

    ul, ol {
      margin: 0 0 1rem 1.5rem;
      padding: 0;
    }

    code {
      font-family: var(--mono);
      font-size: 0.9em;
      background: var(--border);
      padding: 0.1em 0.3em;
      border-radius: 4px;
    }

    pre {
      margin: 0 0 1.25rem;
      padding: 12px 14px;
      background: var(--code-bg);
      color: var(--code-fg);
      border-radius: 8px;
      overflow-x: auto;
      font-family: var(--mono);
      font-size: 0.9rem;
    }

    pre code {
      background: transparent;
      padding: 0;
      border-radius: 0;
    }

    pre code.hljs,
    .hljs {
      background: var(--code-bg) !important;
      color: var(--code-fg) !important;
    }

    .section {
      margin-bottom: 32px;
      border-left: 3px solid var(--border);
      padding: 10px;
    }

    .tagline {
      font-size: 0.95rem;
      color: var(--muted);
      margin-top: 8px;
    }

    .api-heading {
      margin-top: 32px;
      padding-top: 8px;
      border-top: 1px solid var(--border);
    }

    .callout-list {
      list-style: none;
      margin: 0 0 1rem;
      padding: 0;
    }

    .callout-list li::before {
      content: "•";
      margin-right: 0.5rem;
      color: var(--accent);
    }

    .callout-list li { margin-bottom: 0.25rem; }

    .header-content {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      flex-wrap: wrap;
      gap: 24px;
    }

    .header-content nav ul {
      list-style: none;
      margin: 0;
      padding: 0;
      display: flex;
      gap: 24px;
    }

    .header-content nav a {
      color: var(--fg);
      text-decoration: none;
      font-weight: 500;
    }

    .header-content nav a:hover {
      color: var(--accent);
      text-decoration: underline;
    }

    .navbar{
      position: sticky;
      top: 0;
      z-index: 9999;

      background: rgba(245,245,245,.82);
      backdrop-filter: blur(10px);
      -webkit-backdrop-filter: blur(10px);

      border-bottom: 1px solid var(--border);
    }

    .nav-inner{
      max-width: 840px;
      margin: 0 auto;
      padding: 10px 16px;

      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 14px;
    }

    .brand{
      display: inline-flex;
      align-items: baseline;
      gap: 10px;
      text-decoration: none;
    }

    .brand-logo{
      font-family: 'Pacifico', cursive;
      color: var(--accent);
      letter-spacing: 2px;
      font-size: 1.25rem;
      line-height: 1;
    }

    .nav-links{
      list-style: none;
      margin: 0;
      padding: 0;

      display: flex;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
      justify-content: flex-end;
    }

    .nav-links a{
      display: inline-flex;
      align-items: center;
      height: 36px;
      padding: 0 12px;

      border-radius: 999px;
      color: var(--fg);
      text-decoration: none;
      font-weight: 600;
      font-size: 0.95rem;

      transition: transform .15s ease, background .15s ease, color .15s ease, box-shadow .15s ease;
    }

    .nav-links a:hover{
      background: rgba(36,102,60,.10);
      color: var(--accent);
      box-shadow: 0 4px 14px rgba(0,0,0,.06);
      transform: translateY(-1px);
    }

    /* NEW: active section highlight */
    .nav-links a.active{
      background: rgba(36,102,60,.16);
      color: var(--accent);
      box-shadow: 0 8px 18px rgba(0,0,0,.08);
    }

    .navbar.scrolled{
      box-shadow: 0 10px 24px rgba(0,0,0,.08);
    }
    /* Hide brand logo until scrolled past header */
    .brand-logo {
      opacity: 0;
      transform: translateY(-4px);
      pointer-events: none;
      transition: opacity .2s ease, transform .2s ease;
    }

    /* Show brand logo after scrolling */
    .navbar.show-logo .brand-logo {
      opacity: 1;
      transform: translateY(0);
      pointer-events: auto;
    }
  </style>
</head>

<body>
  <nav class="navbar" id="topnav">
    <div class="nav-inner">
      <a class="brand" href="#top">
        <span class="brand-logo">mongoKV</span>
      </a>

      <ul class="nav-links">
        <li><a href="#guide" data-spy="guide">Guide</a></li>
        <li><a href="#api" data-spy="api">API</a></li>
        <li><a href="https://github.com/patx/mongokv" target="_blank" rel="noreferrer">GitHub</a></li>
        <li><a href="https://pypi.org/project/mongokv/" target="_blank" rel="noreferrer">PyPI</a></li>
      </ul>
    </div>
  </nav>

  <div class="page" id="top">
    <header>
      <div class="header-content">
        <div>
          <h1><span class="logo">mongoKV</span> <span class="pronounce">/ˈmɑːŋɡoʊ kiː ˈvæljuː/</span></h1>
          <p class="subtitle">Tiny async/sync key–value store on top of PyMongo.</p>
          <p class="tagline">
        <a href="https://harrisonerd.com/" target="_blank" rel="noopener noreferrer">Harrison Erd</a>
        &middot; BSD 3-Clause
        </p>
        </div>

      </div>
    </header>

    <section class="section" style="border: 0px;">
      <h2 id="guide">User Guide</h2>
      <p><code>mongoKV</code> is a tiny key–value store wrapper around MongoDB using PyMongo.</p>
      <ul class="callout-list">
        <li>One collection per instance, one document per key</li>
        <li>Works synchronously and asynchronously with the same API</li>
        <li>Stores any BSON-serializable Python object as the value, keys are strings</li>
        <li>Automatically generates IDs when you don&rsquo;t care about the key</li>
        <li>Fully tested. MongoDB handles locking, durability, and concurrency.</li>
        <li>This project is intentionally small, stable, and boring. Breaking changes are avoided</li>
        <li>mongoKV is not a cache or a Redis replacement — it’s a simple, durable KV API on top of MongoDB</li>
      </ul>
      <p>Think of it as <em>MongoDB as a dict instead of a database, for lazy programmers who just want to save some simple data.</em></p>
    </section>

    <section class="section" style="border: 0px;">
      <h3>Installation</h3>
      <pre><code class="language-bash">pip install mongokv</code></pre>
      <p>You&rsquo;ll also need a running MongoDB instance and a valid connection URI, for example:</p>
      <pre><code class="language-bash">mongodb://localhost:27017</code></pre>
      <p>Check out <a href="https://www.mongodb.com/resources/products/fundamentals/mongodb-connection-string">An Introduction to MongoDB Connection Strings</a> 
         for help on getting a MongoDB instance up and running. A super easy way to do this is to use <a href="https://www.mongodb.com/products/platform/atlas-database">Atlas</a>,
         that way your instance is in the cloud and you won't need a Mongo server running on your local device.</p>
    </section>

    <section class="section" style="border: 0px;">
      <h3>Core Concepts</h3>

      <p>Each key is stored as a single MongoDB document:</p>
      <pre><code class="language-json">{
  "_id": "&lt;str(key)&gt;",
  "value": &lt;your_data&gt;
}</code></pre>

      <ul>
        <li>Keys are always stored as strings (<code>str(key)</code>), but you can pass any object with a sensible <code>str()</code>.</li>
        <li>Values must be BSON-serializable (anything PyMongo can store: dicts, lists, ints, strings, datetimes, etc.).</li>
        <li>This is a normal MongoDB collection. You can always access the same data directly using PyMongo as your needs grow — no migrations required.</li>
        <li>Safe across threads, processes, and ASGI workers — MongoDB handles locking and atomicity.</li>
        <li>The library auto-detects whether it&rsquo;s running inside an <code>asyncio</code> event loop:
          <ul>
            <li><strong>Inside async code</strong>: methods return <strong>coroutines</strong> &rarr; you must <code>await</code> them.</li>
            <li><strong>Outside async code</strong>: methods run <strong>synchronously</strong> and return results directly.</li>
          </ul>
        </li>
      </ul>

      <p>Same methods, two modes. Start simple, grow into MongoDB when you need more.</p>
    </section>


    <section class="section" style="border: 0px;">
      <h3>Quickstart</h3>

      <h4>Synchronous usage</h4>
      <pre><code class="language-python">from mongokv import Mkv

db = Mkv("mongodb://localhost:27017")

# Set a value with an explicit key
db.set("username", "harrison")

# Get it back
print(db.get("username"))  # -&gt; "harrison"

# Set a value with an auto-generated key
note_id = db.set(None, {"title": "mongoKV", "tags": ["mongo", "kv"]})
print(note_id)  # -&gt; e.g. "677eec65e92eeca23513fe99"

# List all keys
print(db.all())  # -&gt; ["username", "677eec65e92eeca23513fe99", ...]

# Remove a key
db.remove("username")

# Clear everything
db.purge()

# Close connections when you’re done
db.close()</code></pre>

      <h4>Asynchronous usage</h4>
      <pre><code class="language-python">import asyncio
from mongokv import Mkv

db = Mkv("mongodb://localhost:27017")

async def main():
    # Set a value with explicit key
    await db.set("counter", 1)

    # Increment and save again
    current = await db.get("counter", 0)
    await db.set("counter", current + 1)

    # Auto-generated key
    new_id = await db.set(None, {"hello": "world"})
    print("New id:", new_id)

    # List all keys
    keys = await db.all()
    print("Keys:", keys)

    # Remove key
    deleted = await db.remove("counter")
    print("Deleted:", deleted)

    # Purge all keys
    await db.purge()

    # Close connections
    await db.close()

asyncio.run(main())</code></pre>
    </section>

    <section class="section api-heading" style="border: 0px;">
      <h2 id="api">API Reference</h2>
    </section>

    <section class="section">
      <h3><code>class Mkv</code></h3>
      <p>Create a new mongoKV instance.</p>
      <h4>Parameters</h4>
      <ul>
        <li><code>mongo_uri</code> (<code>str</code>): MongoDB connection string.</li>
        <li><code>db_name</code> (<code>str</code>, default <code>"mkv"</code>): Database name.</li>
        <li><code>collection_name</code> (<code>str</code>, default <code>"kv"</code>): Collection name.</li>
      </ul>
      <p>Internally it maintains:</p>
      <ul>
        <li>An async client &amp; collection:
          <ul>
            <li><code>self._async_client = AsyncMongoClient(mongo_uri)</code></li>
            <li><code>self.db</code>, <code>self.collection</code></li>
          </ul>
        </li>
        <li>A sync client &amp; collection:
          <ul>
            <li><code>self._sync_client = MongoClient(mongo_uri)</code></li>
            <li><code>self._sync_db</code>, <code>self._sync_collection</code></li>
          </ul>
        </li>
      </ul>
      <p>These are used automatically based on sync/async context.</p>
    </section>

    <section class="section">
      <h3><code>set</code></h3>
      <p>Set a value for a given key. If <code>key</code> is <code>None</code>, a new ObjectId-based key will be generated.</p>

      <h4>Behavior</h4>
      <ul>
        <li><strong>Sync</strong>: executes immediately, returns the string key.</li>
        <li><strong>Async</strong>: returns a coroutine; awaiting it yields the string key.</li>
      </ul>

      <h4>Parameters</h4>
      <ul>
        <li><code>key</code> (<code>str</code> or <code>None</code>):
          <ul>
            <li>If not <code>None</code>: converted to <code>str(key)</code> and used as <code>_id</code>.</li>
            <li>If <code>None</code>: a new <code>ObjectId()</code> is generated and used as <code>_id</code>.</li>
          </ul>
        </li>
        <li><code>value</code> (<code>Any</code>): Any BSON-serializable object to store.</li>
      </ul>

      <h4>Returns</h4>
      <p><code>str</code>: The key used for this record.</p>

      <h4>Examples (sync)</h4>
      <pre><code class="language-python">db.set("user:1", {"name": "Alice"})
auto_id = db.set(None, {"name": "Bob"})</code></pre>

      <h4>Examples (async)</h4>
      <pre><code class="language-python">key = await db.set("session:123", {"state": "active"})
new_id = await db.set(None, {"foo": "bar"})</code></pre>
    </section>

    <section class="section">
      <h3><code>get</code></h3>
      <p>Fetch the value for a given key.</p>

      <h4>Behavior</h4>
      <ul>
        <li><strong>Sync</strong>: executes immediately.</li>
        <li><strong>Async</strong>: returns a coroutine; awaiting it yields the result.</li>
        <li><strong>Missing keys</strong>:
          <ul>
            <li>If <code>default</code> is <em>not</em> provided, a missing key raises <code>KeyError</code>.</li>
            <li>If <code>default</code> <em>is</em> provided (even <code>None</code>), that value is returned when missing.</li>
          </ul>
        </li>
      </ul>

      <h4>Parameters</h4>
      <ul>
        <li><code>key</code> (<code>str</code>): Key to look up (converted to <code>str(key)</code> for <code>_id</code>).</li>
        <li><code>default</code> (<code>Any</code>, optional): Fallback to return if the key does not exist. If omitted, missing keys raise <code>KeyError</code>.</li>
      </ul>

      <h4>Returns</h4>
      <p>The stored value. If the key is missing, returns <code>default</code> if provided, otherwise raises <code>KeyError</code>.</p>

      <h4>Examples (sync)</h4>
      <pre><code class="language-python"># strict (dict-like)
try:
    user = db.get("user:1")
except KeyError:
    user = None

# fallback behavior
user = db.get("user:1", default={})
missing = db.get("nope", default="N/A")   # -&gt; "N/A"
none_ok = db.get("nope", default=None)    # -&gt; None</code></pre>

      <h4>Examples (async)</h4>
      <pre><code class="language-python"># strict (dict-like)
try:
    user = await db.get("user:1")
except KeyError:
    user = None

# fallback behavior
user = await db.get("user:1", default={})
none_ok = await db.get("nope", default=None)</code></pre>
    </section>

    <section class="section">
      <h3><code>remove</code></h3>
      <p>Delete a single document by key.</p>

      <h4>Behavior</h4>
      <ul>
        <li><strong>Sync</strong>: executes immediately, returns <code>True</code> if a document was deleted, <code>False</code> otherwise.</li>
        <li><strong>Async</strong>: returns a coroutine; awaiting it yields <code>True</code> or <code>False</code>.</li>
      </ul>

      <h4>Parameters</h4>
      <ul>
        <li><code>key</code> (<code>str</code>): Key to remove (converted to <code>str(key)</code> for <code>_id</code>).</li>
      </ul>

      <h4>Returns</h4>
      <p><code>bool</code>: <code>True</code> if a document was deleted, <code>False</code> otherwise.</p>

      <h4>Examples (sync)</h4>
      <pre><code class="language-python">deleted = db.remove("user:1")
if deleted:
    print("User removed")</code></pre>

      <h4>Examples (async)</h4>
      <pre><code class="language-python">deleted = await db.remove("user:1")</code></pre>
    </section>
    
    <section class="section">
      <h3><code>all</code></h3>
      <p>List all keys (all <code>_id</code> values) in the collection.</p>

      <h4>Behavior</h4>
      <ul>
        <li><strong>Sync</strong>: executes immediately, returns a <code>list[str]</code>.</li>
        <li><strong>Async</strong>: returns a coroutine; awaiting it yields a <code>list[str]</code>.</li>
      </ul>

      <h4>Returns</h4>
      <p><code>list[str]</code>: All keys currently stored.</p>

      <h4>Examples (sync)</h4>
      <pre><code class="language-python">keys = db.all()
print(keys)  # -&gt; ["user:1", "note:abc", ...]</code></pre>

      <h4>Examples (async)</h4>
      <pre><code class="language-python">keys = await db.all()</code></pre>
    </section>

    <section class="section">
      <h3><code>purge</code></h3>
      <p>Delete <strong>all</strong> documents in the collection. Use with care.</p>

      <h4>Behavior</h4>
      <ul>
        <li><strong>Sync</strong>: executes immediately, returns <code>True</code> once the operation completes.</li>
        <li><strong>Async</strong>: returns a coroutine; awaiting it yields <code>True</code>.</li>
      </ul>

      <h4>Returns</h4>
      <p><code>bool</code>: Always <code>True</code> if no exception is raised.</p>

      <h4>Examples (sync)</h4>
      <pre><code class="language-python">db.purge()  # All keys gone</code></pre>

      <h4>Examples (async)</h4>
      <pre><code class="language-python">await db.purge()</code></pre>
    </section>

    <section class="section">
      <h3><code>close</code></h3>
      <p>Close both the async and sync MongoDB clients.</p>

      <h4>Behavior</h4>
      <ul>
        <li><strong>Sync</strong>:
          <ul>
            <li>Calls <code>self._async_client.close()</code> using <code>asyncio.run()</code>.</li>
            <li>Calls <code>self._sync_client.close()</code>.</li>
            <li>Returns <code>None</code>.</li>
          </ul>
        </li>
        <li><strong>Async</strong>: returns a coroutine that:
          <ul>
            <li><code>await self._async_client.close()</code></li>
            <li><code>self._sync_client.close()</code></li>
            <li>Awaited result is <code>None</code>.</li>
          </ul>
        </li>
      </ul>
      
      <h4>Returns</h4>
      <p><code>None</code></p>

      <h4>Examples (sync)</h4>
      <pre><code class="language-python">db.close()</code></pre>

      <h4>Examples (async)</h4>
      <pre><code class="language-python">await db.close()</code></pre>

      <p>Closing is optional in short-lived scripts, but recommended in long-running apps and tests.</p>
    </section>

    <section class="section">
      <h3>When is it sync vs async?</h3>
      <p>The library uses:</p>
      <pre><code class="language-python">def in_async() -&gt; bool:
    try:
        asyncio.get_running_loop()
        return True
    except RuntimeError:
        return False</code></pre>
      <ul>
        <li>If there is a running event loop, methods like <code>set</code>, <code>get</code>, etc. return <strong>coroutines</strong>.</li>
        <li>If there is no running loop, they execute synchronously.</li>
        <li>If you do not await your method calls in an async context there will be an error because your a returning a non-awaited coroutine.</li>
      </ul>
    </section>
    
    <section class="section">
      <h3>Error Handling</h3>
      <p>The current implementation doesn&rsquo;t wrap MongoDB errors – any connection or operation issue will bubble up as a PyMongo/AsyncMongo exception. Typical things you might see:</p>
      <ul>
        <li>Connection failures</li>
        <li>Serialization errors for non-BSON-compatible values</li>
        <li>Authentication/authorization errors if your URI is locked down</li>
      </ul>
      <p>You can catch them at the call site:</p>
      <pre><code class="language-python">from pymongo.errors import PyMongoError

try:
    db.set("x", object())
except PyMongoError as e:
    print("Mongo blew up:", e)</code></pre>
    </section>

  </div>
  
<script defer>
  document.addEventListener("DOMContentLoaded", () => {
    document.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));

    const nav = document.getElementById("topnav");
    const header = document.querySelector("header");

    const guideEl = document.getElementById("guide");
    const apiEl   = document.getElementById("api");

    const guideLink = document.querySelector('.nav-links a[href="#guide"]');
    const apiLink   = document.querySelector('.nav-links a[href="#api"]');

    const clearActive = () => {
      [guideLink, apiLink].forEach(a => a && a.classList.remove("active"));
    };

    const setActive = (which) => {
      clearActive();
      if (which === "guide" && guideLink) guideLink.classList.add("active");
      if (which === "api" && apiLink) apiLink.classList.add("active");
    };

    // Decide which section should be active RIGHT NOW (when spy is enabled)
    const computeActiveSection = () => {
      if (!guideEl || !apiEl) return null;

      const y = 90; // "reading line" just below sticky nav
      const g = guideEl.getBoundingClientRect();
      const a = apiEl.getBoundingClientRect();

      const guidePassed = g.top <= y;
      const apiPassed   = a.top <= y;

      if (apiPassed) return "api";

      if (guidePassed) return "guide";

      return null;
    };

    let spyEnabled = false;

    const syncNavState = () => {
      const headerBottom = header.getBoundingClientRect().bottom;
      const shouldEnable = headerBottom <= 0;

      nav.classList.toggle("scrolled", window.scrollY > 2);
      nav.classList.toggle("show-logo", shouldEnable);

      if (!shouldEnable) {
        spyEnabled = false;
        clearActive();
        return;
      }

      spyEnabled = true;

      const which = computeActiveSection();
      if (which) setActive(which);
      else clearActive();
    };

    syncNavState();
    window.addEventListener("scroll", syncNavState, { passive: true });
    window.addEventListener("resize", syncNavState, { passive: true });

    const onNavClick = (which) => (e) => {
      if (!spyEnabled) return;
      setActive(which);
    };
    if (guideLink) guideLink.addEventListener("click", onNavClick("guide"));
    if (apiLink) apiLink.addEventListener("click", onNavClick("api"));

    if ("IntersectionObserver" in window && guideEl && apiEl) {
      const observer = new IntersectionObserver(() => {
        if (!spyEnabled) return;
        const which = computeActiveSection();
        if (which) setActive(which);
        else clearActive();
      }, {
        root: null,
        rootMargin: "-72px 0px -55% 0px",
        threshold: [0, 0.1, 0.25, 0.5]
      });

      observer.observe(guideEl);
      observer.observe(apiEl);
    }
  });
</script>

</body>
</html>