npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nytka/plugin-sanity

v0.3.2

Published

Sanity content-lake connector for nytka projects. Exports documents into datasets/ and registers them with provenance. Read-only.

Readme

@nytka/plugin-sanity

Sanity content-lake connector for nytka projects. Exports documents into the project's datasets/ and registers them in datasets/index.json with provenance.

Read-only, by construction. The handle this package hands out carries one method — fetch — over a query function that never escapes the closure that made it. Underneath, the Data API's query endpoint is the only endpoint path that appears anywhere in src/: every request is built from one constant, so the mutating endpoints are not merely unused, they are not spellable. A test greps the source for that and a second test checks the URL of every request the connector actually issues. That is a structural guarantee, not a promise to be careful.

What makes this connector different

It is the first nytka connector over a content store rather than a metrics API, and the first whose payload is documents rather than rows.

@nytka/plugin-gsc, @nytka/plugin-ga4 and @nytka/plugin-dataforseo all return a table of numbers over a date range. Sanity returns nested, typed, cross-referencing documents with no time dimension and no natural aggregation. Three things follow, and each one is a section below rather than a footnote:

Install

npm install @nytka/plugin-sanity

One dependency: @nytka/core, which has no dependencies of its own and holds the project plumbing every connector shares. 112 KB installed, 2 packages, 11 files.

Until 0.2.0 this also pulled @sanity/client, and the whole install was 18.5 MB across 22 packages and 2 652 files — 11 MB of it rxjs, for an Observable API this connector never touched, and 5.3 MB @sanity/client's own dist/ carrying browser, edge, worker, react-server, deno, bun, CJS and ESM builds of the same code. (Both figures are du -k on a clean install, measured 2026-07-28 on macOS; by apparent file size rather than blocks it is 10.8 MB → 88 KB.)

That was never proportionate to what is used here. The read path is one authenticated request to https://<projectId>.api.sanity.io/v<date>/data/query/<dataset>?query=…, which built-in fetch does with no dependency at all. What the client genuinely did for it was four things, and all four are reimplemented against the library's own source rather than dropped:

| | | |---|---| | Retries | 429, 502 and 503 retry five times on 100 × 2ⁿ backoff, as does a dropped connection whose error code says it could succeed next time. A wrong token, a missing dataset and a bad query never retry. | | Typed errors | SanityHttpError carries statusCode and the API's own description, which is what the error messages below are built on. | | perspective | passed on every request, on both request shapes. Defaults to published — see Drafts. | | GET → POST | a query whose encoded string reaches 11 264 characters is sent as a POST with the query and its parameters in the body. A --filter naming a few hundred types gets there; without the switch it would 414 at the edge. |

The full argument, and every place the reimplementation deliberately differs from what @sanity/client did, is in the comments in src/sanity.mjs. @nytka/plugin-dataforseo already shipped this way.

Setup

About five minutes. Auth is a bearer token — the third credential mechanism in the line, after the Google service-account key file and DataForSEO's basic auth, and the second with no file to put in private/. The value in .env is the credential; there is no path to resolve and nothing to download.

1. Create a token

https://sanity.io/manage → your project → API → Tokens → Add API token.

Sanity issues several roles for a token. This package reads a different .env variable for each level it supports, so the access level is visible in .env without running anything:

| Role | Variable | |---|---| | Viewer | SANITY_TOKEN_VIEWER | | Editor | SANITY_TOKEN_EDITOR |

A plain export or query only ever needs Viewer — this connector cannot write regardless of which token it holds (see What makes this connector different above). Set SANITY_TOKEN_EDITOR in addition only if you also want nytka-sanity mcp to emit a write-capable MCP config — see MCP config below. Name each token something like <project>-nytka-viewer or <project>-nytka-editor, so its role stays legible next to the value that leaks.

Sanity's Administrator role is not one of the two — this package never reads a token that strong, on purpose. An Administrator token can create and delete datasets, manage API keys and project members, and run migrations; none of that is content work, and all of it is destructive. Adding a third variable for it would be an invitation to fill it in with the most powerful token available the moment some other error needs to go away, the same reasoning ../nytka's decision 0008 applies in withholding the tagmanager.publish scope from the write-capable GTM connector. If Administrator access is ever genuinely needed here, that is a new decision, not a new variable.

2. Read the project id and dataset

Both are on the same page. The project id is the short code (abcd1234), not the studio hostname — pasting myproject.sanity.studio is the single most common setup error and it surfaces as a DNS failure, because the project id becomes the API hostname. The connector catches that spelling before it becomes a network error.

The dataset is almost always production.

3. Configure

One .env at the project root. Every key for the project lives there and nowhere else.

The names below ship with the package too, so recovering them later never means re-reading this guide:

cat node_modules/@nytka/plugin-sanity/.env.example >> .env
SANITY_PROJECT_ID=abcd1234
SANITY_DATASET=production
SANITY_TOKEN_VIEWER=sk...

The project id, the dataset and at least one token variable are demanded at once. A token without a project is not a partial configuration, it is an unusable one, and reporting the first missing piece one run at a time wastes three runs.

The access level lives in the variable name, not in a role field or a flag — reading .env tells you what a credential can do without asking the process anything, and the emitted MCP config (below) inherits that same role, so a Viewer token gives you a read-only MCP for exactly the reason it gives you a read-only connector. The two commands resolve the two variables in opposite orders, and each has its own reason:

  • The data commands (schema, export, query) resolve SANITY_TOKEN_VIEWER, then SANITY_TOKEN_EDITOR — least privilege first, because this connector only ever reads and should hold the weakest token that does the job.
  • mcp resolves the opposite order — SANITY_TOKEN_EDITOR, then SANITY_TOKEN_VIEWER — because the reason to want an MCP at all is to edit through an agent, so an Editor token, if present, is taken as what you meant. A Viewer token still produces a valid MCP, just a read-only one, since Sanity scopes every tool call to the token's own role.

4. Verify

npx nytka-sanity schema

Listing your document types means the token, the project id and the dataset are all right.

Use

npx nytka-sanity schema                       # auth smoke test — types and document counts
npx nytka-sanity schema --sample 0            # counts only, the cheapest call there is
npx nytka-sanity schema --sample 20           # better field coverage, more data over the wire

npx nytka-sanity export --type blogPost       # every blogPost, paginated to exhaustion
npx nytka-sanity export --type blogPost --projection '{_id, _rev, title, slug}'
npx nytka-sanity export --type blogPost --limit 100

npx nytka-sanity query --filter '_type == "service" && defined(slug.current)'
npx nytka-sanity query --groq 'count(*[_type == "blogPost"])'

npx nytka-sanity export --type blogPost --snapshot      # keep this collection as a dated one
npx nytka-sanity export --type blogPost --no-register   # write the payload, leave the registry

Programmatic:

import { run, client, discoverTypes } from '@nytka/plugin-sanity'

const { id, documents, contentHash, rawPath } = await run({ kind: 'export', type: 'blogPost' })

run() returns counts, hashes and paths, never documents. Every connector in the line holds that rule; here it is load-bearing rather than tidy. A GSC monthly payload is sixteen rows of four numbers. A content export is every Portable Text body on the site, and handing that back to an agent loop is not a large context, it is the end of one.

Drafts

--perspective defaults to published. The API's own default is raw — as was @sanity/client's — which returns drafts alongside their published counterparts — so a raw export of a type contains two entries for every document currently being edited, one of them work in progress, and nothing in the payload distinguishes them except an _id prefix. An export that silently doubles a subset of its rows is the document-store version of counting a partial day as a traffic collapse.

Pass --perspective raw deliberately when you want drafts. The perspective used is recorded in the payload and the registry entry.

MCP config

npx nytka-sanity mcp

Sanity ships a hosted MCP server at https://mcp.sanity.io, and this prints the config block for it — built from whichever of SANITY_TOKEN_EDITOR or SANITY_TOKEN_VIEWER is set, in that order (Editor first — the opposite of the data commands, see 3. Configure above for why):

{ "mcpServers": { "Sanity": { "url": "https://mcp.sanity.io", "headers": { "Authorization": "Bearer sk..." } } } }

Stdout carries only that block. Which variable was actually used is printed to stderr, never stdout, so a pipe stays pure JSON and a human running it interactively still sees whether the emitted config can write:

npx nytka-sanity mcp > sanity-mcp.json
# stderr: mcp: using SANITY_TOKEN_EDITOR (editor token) — the emitted config inherits this role

npx nytka-sanity mcp | pbcopy
# stderr still prints to the terminal even though stdout went to the clipboard

This connector cannot switch the MCP on. An MCP server is spoken to by the agent — Claude Code, Claude Desktop — through the agent's own config, and a package installed from npm has no business editing that file behind you: which file, which agent and which scope are your call, not this package's. So the command only prints to stdout, on purpose, so the block can be piped straight into wherever it belongs.

The emitted MCP inherits the resolved token's role, because project id and dataset are not part of the config at all — Sanity's MCP takes those per tool call, so the token is the only input this needs, and it is the only thing that governs what the MCP can do once connected. Point this at a Viewer token and the MCP is read-only for the same reason the connector is: a tool call is scoped to the token's own permissions. Point it at an Editor token and the emitted MCP can write — this command does not create or upgrade a token, it only reports which one it found and resolves Editor first because wanting an MCP at all is usually about editing through an agent. There is no Administrator level here to resolve towards: this package never reads SANITY_TOKEN_ADMIN, so an emitted MCP is never stronger than Editor. See 1. Create a token above for why.

Neither variable set is an unconfigured capability, not an error: the command names both and exits 0 rather than throwing. The token is never logged, echoed into a diagnostic, or written to a file by this command — the config block on stdout is the only place it appears, and the stderr report never includes the token's value, only the variable name and the role it names.

What schema can and cannot tell you

It derives types from the documents. It does not introspect, because there is nothing to introspect.

The Sanity content lake is schemaless. A document is JSON with a _type string, and everything that gives _type meaning — field types, validation rules, titles, reference targets, internationalisation — lives in the Studio's TypeScript and is never uploaded alongside the content. The Data API has no endpoint that describes types. array::unique(*[]._type) is the whole of what a content token can be told, and this command is that query plus a batch of count()s plus a sample of documents whose top-level keys are counted client-side.

Two consequences, stated rather than papered over:

| | | |---|---| | A type with no documents | invisible here, exists in the Studio schema | | A field no sampled document filled in | invisible here, exists in the Studio schema |

So the field count is a lower bound, and the command prints that under its own output. --sample N widens the sample; the Studio schema is the only real answer. If your project keeps a checked-in schema.json (sanity schema extract), that file is the authority and this command is a cross-check against what is actually stored.

Why --filter pages and --groq does not

This is the finding most likely to matter to another connector.

In a row API the paging cursor is a request parameter sitting beside the query: Search Console takes startRow, the GA4 Data API takes offset. The query body and the cursor are different fields, so a connector can page any query a caller hands it without understanding the query at all.

GROQ has no such parameter. The cursor is part of the query text — *[… && _id > $lastId] | order(_id) [0...100]. To page a query it did not write, a connector would have to parse the GROQ, decide whether appending && _id > $x changes the result, and re-slice around an existing | order(). Getting that subtly wrong returns confidently incomplete content with no error, which is the worst failure this line has a name for.

So the mode is the caller's explicit choice and the payload records which was used:

| | --filter | --groq | |---|---|---| | Who writes the query | the connector, from your filter | you, verbatim | | Paginated | yes, keyset on _id, to exhaustion | no — one request | | Safe at any size | yes | only if you sliced it yourself | | Can return an aggregate | no | yes (count(*[…])) | | Can order by anything but _id | no | yes | | paginated in the registry entry | true | false |

Keyset (_id > $lastId) rather than slicing ([1000...1020]) because slicing makes the engine fetch and discard everything before the window, so it degrades as the dataset grows — this is Sanity's own documented advice, not a preference.

A projection under --filter must keep _id, because _id is the cursor. Drop it and the next request repeats the same page for ever; the connector fails with that sentence rather than hanging.

What it writes

| Path | Committed? | |---|---| | datasets/payloads/<id>.json | no — the directory ships its own .gitignore | | datasets/index.json | yes — one entry, added or replaced |

The dataset id

export    sanity-abcd1234-production-blogpost
                 └───┬───┘ └───┬────┘ └──┬───┘
                 project   dataset   document type

query     sanity-abcd1234-production-q-4f2a9c1e
                                        └──┬───┘  hash of the composed query + its params

named     sanity-abcd1234-production-q-published-posts        --name "Published posts"
snapshot  sanity-abcd1234-production-blogpost-2026-07-28      --snapshot

The id names the question, never the answer. No date and no content hash, so re-running an unchanged collection replaces its entry in place, and re-running after an edit also replaces it — only the contentHash in the entry moves.

The failure that prevents: an id that moves when nothing was asked differently. registerDataset matches on id, so anything content-derived in an id appends a fresh entry on every change and the registry fills with near-duplicate descriptions of one question. That is the same bug PLG-007 fixed for time series, in the form a content store takes.

Two alternatives were rejected, each carrying a failure this one does not:

  • type + content hash — changes on every trivial edit. One typo fix in one document of 400 mints a new id, a new payload file and a new registry entry, while the old entry stays status: current describing content that no longer exists. The registry then grows at the rate the CMS is edited, which for a CMS is the point of owning one.
  • type + collection date — accumulates near-duplicates on a schedule: 365 entries a year per type, 364 stale, each keeping a full payload on disk. Identical in kind to the thirty-entries-a-month bug PLG-007 already paid for once.

The scheme adopted has its own failure — re-running overwrites yesterday's collection — and it is accepted for two reasons. First, it is the only one of the three that loses no answer: a content lake has no periods, so yesterday's export is not an observation of a different period, it is a stale mirror of the same question. GSC's October and November are two facts; a content export from yesterday and one from today are one fact twice. Second, it is opt-out rather than silent — --snapshot puts the collection date in the id and hands the accumulating behaviour to anyone who genuinely wants a point-in-time series, which is a different operation and now says so in its own id.

The content hash is not discarded, only moved out of the identity and into the entry. It is the sorted set of _id@_rev pairs — Sanity stamps every document with _rev on every write, so the revision set is an exact content fingerprint with no need to hash bodies and no false differences from key ordering. Comparing two collections' contentHash still answers "did anything change"; it just no longer forks the dataset's identity to do it.

The registry entry

It carries the cross-connector fields a registry reader already knows — source, operation, property, collectedAt, rawPath, rows, schema, status, producedBy — plus four this shape needs:

| Field | Why | |---|---| | dateRange: null | Explicitly null, not omitted. Every gsc/ga4 entry beside it carries a real range and this one cannot. Writing the absence down stops a reader taking the gap for a collection bug. | | documents | The true name of the count. rows holds the same number so a registry reader can still count records without a per-source special case — the duplication is deliberate and is the thing PLG-001 should collapse into one shape-neutral field. | | contentHash | The identity of the content, kept out of the id on purpose (above). | | paginated | false means the result came from one unpaged request and may be incomplete. |

schema here is the observed union of top-level field names, not a declared schema, with per-field document counts kept in the payload's fieldCoverage. The row connectors get a rectangle where every row has every column; documents do not. Two documents of one type differ in which optional fields they carry, and fields nest arbitrarily deep — so "the type has a seo field" and "3 of 400 documents filled it in" are different facts and only the second is derivable from content.

Every date it writes is a local calendar date, like every other date in a nytka project.

The registry writer preserves the file's existing formatting: adding one dataset produces a one-entry diff, not a reformat of every entry already there.

Rules it follows

  • Payloads never enter agent context. Query them with a script; write conclusions to research/. A dataset is evidence, a research item is knowledge. The CLI prints counts, hashes and paths for this reason.
  • The project is found by walking up for project.yaml, so it works at any install depth.
  • No YAML parsing, no config file. Secrets from .env, everything else from flags.
  • Nothing is ever written back to Sanity, and no code path exists that could.
  • mcp only ever prints to stdout. It never writes an agent's config file — which file, which agent and which scope are your call, not a package installed from npm.

Troubleshooting

| What you see | What it means | |---|---| | SANITY_PROJECT_ID, SANITY_DATASET are not set … | .env is missing them — see step 3. All are demanded at once on purpose | | SANITY_TOKEN_VIEWER, SANITY_TOKEN_EDITOR are not set … | Neither token variable is set — see step 1. Setting SANITY_TOKEN_ADMIN alone does not count; it is never read | | … does not look like a Sanity project id | You pasted the studio hostname. Use the short code from sanity.io/manage | | HTTP 401 | Whichever token variable you set is missing, expired, or belongs to a different project. Make a new token at the matching role | | HTTP 403 | The token authenticated but may not read this dataset. Check SANITY_DATASET and the token's role | | HTTP 404 | Usually SANITY_PROJECT_ID or SANITY_DATASET is wrong — a missing project and a missing dataset both 404 | | HTTP 400 | A malformed GROQ query, not an auth problem. Try it in the Studio's Vision tool first | | getaddrinfo ENOTFOUND ….api.sanity.io | The project id becomes the hostname, so this is almost always a wrong SANITY_PROJECT_ID | | the projection must keep _id | _id is the pagination cursor. Add it back, or drop the projection | | a paginated query must return an array | You gave --filter an aggregate. Use --groq for count(*[…]) | | no project.yaml found walking up from cwd | Not inside a nytka project. cd to the project root | | schema lists nothing | The token worked and the dataset is empty — or it is the wrong dataset | | Twice as many documents as expected | --perspective raw includes drafts beside their published versions. Drop the flag | | A field you know exists is not in schema | Field counts come from a sample and are a lower bound — raise --sample | | Neither token variable set, from mcp | Not an error — mcp exits 0 and names both variables on stderr |

An auth failure prints a sentence and a hint, never a stack trace: a stack out of a request pipeline names plumbing and nothing you control. Set NYTKA_DEBUG=1 to see it anyway.

Limits worth knowing

  • schema is derivation, not introspection. Empty types and unfilled fields are invisible. See the section above.
  • --groq is not paginated and cannot be. See the section above.
  • The API is pinned to a dated version (v2025-02-19). An unpinned apiVersion silently follows the newest release, so a query that works today can change meaning on an upgrade the project never made.
  • 100 documents per request while paging. Not an API cap — Sanity bounds a response by size rather than document count — but a size-safety choice, since a page of large Portable Text bodies is nothing like a page of numbers.
  • useCdn is off. The CDN serves a cached copy, and a connector whose output is provenance-stamped must read the live lake. It is also incompatible with a token.

Tests

npm test

node --test against fixtures. No credential, no network, no client data — the document fixtures are invented and carry only the shape characteristics that matter (optional fields, nesting, references, Portable Text). Nothing from any real Sanity project is vendored here.