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

@mason-data/sdk

v0.3.2

Published

TypeScript client for Mason's query API: typed queries, row-level-secure tokens, any Mason deployment including your own.

Readme

@mason-data/sdk

The TypeScript client for Mason's query API: typed queries over your models, and row-level security through short-lived tokens your backend mints.

It works against any Mason deployment, including one you run yourself — every client takes the base URL of the Mason API it talks to, and nothing in it assumes a hosted service.

npm install @mason-data/sdk

| Import | Runs in | Authenticates with | |---|---|---| | Mason from @mason-data/sdk | browsers, React Native, edge functions, Node | a token | | MasonServer from @mason-data/sdk/server | your backend only | the API key |

No runtime dependencies; ESM; needs a global fetch (Node 18+, every browser) or a fetch option.

Where is Mason?

Pass the base URL of your Mason API — wherever you expose it:

new Mason({ url: "http://localhost:4141", token });                 // a local stack
new Mason({ url: "https://mason.acme.internal", token });           // your own deployment
new Mason({ url: "https://acme.com/analytics/mason", token });      // behind a reverse proxy: the path is kept
new Mason({ url: "/mason", token });                                 // same origin, in a browser

MasonServer reads MASON_URL and MASON_API_KEY from the environment when you don't pass them. Anything your deployment needs in front of it — an auth proxy, a gateway header — goes in headers, which ride on every request.

Tokens: the API key stays on your server

Your backend exchanges the API key for a token restricted to one user's rows, and hands the browser only that token. Every query made with it is filtered to its scope, which the browser cannot change.

// server/mason-token.ts — your backend
import { MasonServer } from "@mason-data/sdk/server";

const mason = new MasonServer({ url: process.env.MASON_URL, apiKey: process.env.MASON_API_KEY });

export async function GET(req: Request) {
  const user = await auth(req);                                // your existing auth
  const token = await mason.createToken({
    scope: { "workspace.id": user.workspaceId },               // every model, filtered to this workspace
    subject: user.id,                                          // recorded with every query it makes
    expiresIn: "15m",                                          // default 1 h, at most 24 h
  });
  return Response.json({ token });
}

A scope is a where whose keys name the model and column that identify a tenant: { "workspace.id": id }, or { "workspace.id": [id1, id2] } for a user in several. {} mints an unrestricted token — deliberately; an omitted scope is an error. A query whose model cannot be restricted by the scope is refused (scope_not_applicable), never run unfiltered.

// revenue.tsx — in the browser
import { Mason } from "@mason-data/sdk";

const mason = new Mason({
  url: process.env.NEXT_PUBLIC_MASON_URL,
  getToken: () => fetch("/api/mason-token").then((r) => r.json()).then((r) => r.token),
});

const { data, meta } = await mason.query({
  model: "order_revenue",
  metrics: ["revenue", "orders"],
  groupBy: ["plan", "placed_at.week"],
  timeRange: "last_12_weeks",
});

getToken is called when the client first needs a token, again shortly before it expires, and once more if Mason rejects it; concurrent queries share one call. Pass token instead for a fixed one.

On the server, mason.as({ scope, subject }) returns a client that queries as that user, and mason.query(...) queries unscoped (every row — for internal tools only).

Writing a query

await mason.query({
  model: "order_revenue",                                   // FROM
  metrics: ["revenue", "orders"],                           // the model's declared metrics
  groupBy: ["plan", "placed_at.week"],                      // GROUP BY, with time buckets
  where: { "customer.country": ["DE", "FR"] },              // WHERE (Prisma's operators)
  timeRange: "last_12_weeks",                               // WHERE <time column> in range
  orderBy: { revenue: "desc" },                             // ORDER BY
  limit: 100,                                               // LIMIT (default 500, max 10 000)
});
  • metrics — metrics the model declares, by name: "revenue", "returning_users(7)" for one that takes arguments, or { metric, args, as }.
  • groupBy — "plan", "customer.country" (a column reached through a join), and either with a time bucket: .minute, .hour, .day, .week (Monday), .month, .quarter, .year, or .hour_of_day, .day_of_week, .day_of_month. No groupBy: one row of totals.
  • select — rows instead of totals: the same entries, no grouping. Never with metrics.
  • Result keys are exactly the names you asked for ("placed_at.week"), unless you give as.
  • timeRange — last_N_hours|days|weeks|months, today, yesterday, this_week, this_month, this_quarter, this_year, or { from, to } (to exclusive). Relative ranges are whole UTC buckets and include the current one.

where

where: {
  status: "paid",                                   // =
  refunded_at: null,                                // IS NULL
  plan: ["pro", "enterprise"],                      // IN
  amount: { gte: 100, lt: 10_000 },                 // >= AND <
  "customer.email": { contains: "@globex.com" },    // case-insensitive substring
  cancelled_at: { not: null },                      // IS NOT NULL
  org_id: { in: { model: "org_members", select: "org_id", where: { role: "admin" } } },  // IN (SELECT ...)
}

Operators: equals, not, in, notIn, gt, gte, lt, lte, contains, notContains, and AND: [...]. Every condition must hold. A Date is sent as its ISO instant. Values are always bound as parameters, never spliced into SQL.

The result

const { data, meta } = await mason.query<{ plan: string; revenue: number }>({ ... });
data[0].revenue;       // typed by the row type you give
meta.servedBy;         // the model or pre-aggregation that answered
meta.scope;            // the token's restriction, null when unrestricted
meta.truncated;        // true when `limit` cut the result
meta.queryId;          // the id it ran under in Mason's query log
meta.durationMs;

Typed queries: mason types

Generate the types of your deployment's models once, and every query is checked against them and its rows typed by what it asks for — no row type to write by hand:

MASON_URL=http://localhost:4141 MASON_API_KEY=… npx @mason-data/sdk types > src/mason-models.ts
import { Mason } from "@mason-data/sdk";
import type { MasonModels, MasonQuery, MasonRow } from "./mason-models";

const mason = new Mason<MasonModels>({ url, getToken });
const { data } = await mason.query({
  model: "order_revenue", metrics: ["revenue"], groupBy: ["placed_at.week", "customer.country"],
});
// data: Array<{ "placed_at.week": string; "customer.country": string | null; revenue: number }>

// a query built outside the call keeps its type
const byPlan = { model: "order_revenue", groupBy: ["plan"], metrics: ["orders"] } as const satisfies MasonQuery;
type ByPlan = MasonRow<typeof byPlan>;   // { plan: string; orders: number }

Code written over one model takes that model's parts by name: MasonQueryOf<"order_revenue">, and its MasonWhere, MasonMetric (a metrics entry), MasonColumn (a groupBy / select entry) and MasonModel (a model's name).

import type { MasonMetric, MasonQueryOf, MasonWhere } from "./mason-models";

async function revenue<const Q extends MasonQueryOf<"order_revenue">>(q: Q) {
  return (await mason.query(q)).data;   // rows typed by what each call asks for
}

// an entry used in several queries: `as const satisfies`, never an annotation
const returning = { metric: "returning_customers", args: [30], as: "returning" } as const satisfies MasonMetric<"order_revenue">;
const paid = { status: "paid" } satisfies MasonWhere<"order_revenue">;
await revenue({ model: "order_revenue", metrics: [returning, "revenue"], where: paid });   // rows: { returning, revenue }
  • A model, column, time bucket, metric or where column the deployment does not have is a compile error; so is a bucket on a column that is not a time, and metrics beside select.
  • An entry declared apart from the query must be as const. Without it TypeScript widens it where it is declared — as: "returning" to string, args: [30] to number[], and an annotation (const m: MasonMetric<…> = …) to every name the type allows — so its result key is not known. The row then says so rather than guess: its only key is the reason (a metrics entry "returning_customers" has no fixed result key, so its rows cannot be typed: declare it \as const``), and the first read of it fails.
  • A mistake is reported against the query's own model: 'plna' does not exist in type 'TypedWhere<MasonModels, "order_revenue">'. Did you mean to write 'plan'?
  • Each column carries its description from the model as a doc comment, so an editor's hover says what it means; a column the model does not describe has none.
  • Values are typed as the API returns them in JSON: a time or a date is a string, a count is a number, a column that can be NULL is T | null, and a column reached through a join is always | null (no matching row). A cyclic bucket (.hour_of_day, .day_of_week, .day_of_month) is a number.
  • GET /v1/models is a key route, so the API key is read from MASON_API_KEY (never a flag, which would land in shell history). The output is deterministic: commit it, and run the command again after a model changes — the diff is what changed. A model not built yet on the deployment is left out (stderr names it).
  • Run it as npx @mason-data/sdk types, never npx mason types: where the SDK is not installed in the folder you run it from, npx mason fetches and runs an unrelated npm package of that name. Inside a project that depends on the SDK, npx @mason-data/sdk runs the installed version.
  • Nothing changes at runtime: the query is sent as written and the server still validates it. Without a type argument, new Mason(...) takes any query, and query<Row>() still types rows by hand.

Errors

A refused or failed query throws a MasonError with the API's stable code:

import { MasonError } from "@mason-data/sdk";

try {
  await mason.query({ ... });
} catch (e) {
  if (e instanceof MasonError && e.code === "history_loading") {
    // the model is still loading its history: e.availableFrom is the date it can answer from
  }
  throw e;
}

| code | status | means | |---|---|---| | invalid_query, unknown_model, unknown_metric, unknown_column, select_and_metrics, no_time_column, fan_out, unbounded_query, not_supported | 400 | the query is wrong — the message says how | | invalid_token / invalid_api_key | 401 | the credential is missing, expired or rotated | | scope_not_applicable | 403 | the token's scope cannot be applied to a model in the query | | history_loading | 409 | the model is still loading history older than availableFrom | | query_failed | 502 | the database refused or failed the statement (queryId, sql) | | overloaded | 503 | too many queries in flight; retry after retryAfterMs | | network_error, timeout, aborted | 0 | no answer from Mason (the SDK's own codes) |

Options

new Mason({
  url: "https://mason.acme.internal",   // required: your Mason API
  getToken: () => fetchToken(),         // or token: "..."
  timeoutMs: 30_000,                    // per request
  headers: { "X-Gateway-Key": "..." },  // on every request
  client: "billing-page",               // a label recorded in Mason's request log
  fetch: customFetch,                   // for tests or unusual runtimes
});

await mason.query(query, { signal });   // cancel with an AbortSignal

MasonServer takes the same options with apiKey instead of a token, plus listModels() and describeModel(name) — every model's columns, metrics, joins, parameters and time column.

Not in this version

The public docs describe some things this API does not do yet, and the SDK does not pretend to: mason.subscribe(...) (live updates), having, fill, timezone, offset, OR / NOT, startsWith / endsWith, and descriptions / meta.freshAsOf in the result. The API refuses the query keys by name rather than ignoring them.

License

MIT — see LICENSE.