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

@revenexx/app-sdk

v1.1.0

Published

Typed data client + HTTP router for revenexx Apps. Swappable adapters (mock | remote | runtime) so the same App code runs against in-memory fixtures locally and the real data plane in production.

Readme

@revenexx/app-sdk

The SDK for building revenexx Apps — typed data access and HTTP routing for the revenexx Revenue Cloud app platform.

A revenexx App declares its data model in a schema.json and its identity and permissions in a manifest.json. The platform provisions the database, API exposure and tenant isolation from those files; your function code only needs two things:

  • a data client to read and write the App's entities (@revenexx/app-sdk)
  • a router to answer the HTTP routes your App exposes (@revenexx/app-sdk/router)
npm install @revenexx/app-sdk

Data client

The client gives every entity the same methods — list, page, get, create, update, updateIf, delete — and the same query shape, independent of where the data lives. Swap the adapter to move between environments:

| adapter | use it for | infrastructure | |-----------|-------------------------------------------------------|----------------| | mock | local development + unit tests against fixtures | none | | remote | integration checks against a real development tenant | none local | | runtime | production, inside the deployed function | auto-configured |

const { createClient } = require('@revenexx/app-sdk');

const db = createClient({
  adapter: 'mock',                 // 'remote' | 'runtime'
  entities: {
    markets: { table: 'acme__shop__markets', pk: 'id' },
  },
  seed: { markets: [{ id: 'm1', code: 'de', currency: 'EUR' }] },
});

await db.markets.create({ code: 'at', currency: 'EUR' });
const eur = await db.markets.list({ where: { currency: 'EUR' }, order: 'code.asc' });
const page = await db.markets.page({ limit: 20, offset: 0 });   // { items, total }

Most Apps don't hand-write the entities map: the revenexx tooling generates a typed db.generated.js from your App's schema.json + manifest.json, so createDb({ adapter, ... }) is one import away and every entity is typed.

Queries

One query shape works across all adapters:

await db.markets.list({
  where: {
    currency: 'EUR',                                  // equality
    code: ['de', 'at'],                               // IN list
    created_at: { op: 'gte', value: '2026-01-01' },   // explicit operator
  },
  select: ['id', 'code'],
  order: 'created_at.desc',
  limit: 50,
  offset: 0,
});

Operators: eq, neq, gt, gte, lt, lte, like, ilike, in, is.

Conditional updates

update writes by id, whatever state the row is in. A state transition that two writers can race — a sweep abandoning a placing order while a re-submit places it — needs the write to hold only if the row is still where the App last saw it. updateIf(id, where, patch) is that compare-and-set, in one request:

const placed = await db.orders.updateIf(id, { status: 'placing' }, { status: 'placed' });
if (!placed) {
  // Someone else moved it first (or it is gone). Re-read and decide.
}
  • where is the query where shape above, ANDed with the id. Postgres re-checks it on the locked row, so of two racing writers exactly one wins.
  • It resolves to the updated row, or null when no row matched — gone, invisible, or the condition no longer holds. One PATCH cannot tell these apart, and a lost race is an outcome to branch on, not an error.
  • An empty where (or a condition left undefined) is a 400: that is an unconditional update, and update is the method for it. An empty patch is a 400 as with update.
  • It needs the update permission. A custom adapter that does not implement updateIf makes the call throw — the client does not emulate it as a read followed by a write, because that is the race.

The same holds for a numeric guard: updateIf(id, { committed: seen }, { committed: seen + amount }), retried on null, is an optimistic budget commit.

Permissions

The methods available per entity follow your App manifest's permissions declarations. Calling an operation the manifest doesn't grant throws a descriptive error (and the platform would reject it anyway) — so local tests catch permission gaps before deploy.

Custom adapters

The adapter is a small interface (list/get/create/update/remove, optional page and updateIf). Register a new backend before constructing the client:

const { registerAdapter, createClient } = require('@revenexx/app-sdk');

registerAdapter('sqlite', (config) => ({ kind: 'sqlite', /* … */ }));
const db = createClient({ adapter: 'sqlite', entities, file: 'dev.db' });

Router

@revenexx/app-sdk/router turns the single function entrypoint into declarative routes. Path templates use the same {param} syntax as your manifest's capability routes.

const { createApp, notFound } = require('@revenexx/app-sdk/router');
const { createDb } = require('./db.generated');

const app = createApp({ name: 'markets' });

app.get('/markets/{id}/context', async (c) => {
  const db = createDb({ adapter: 'runtime', context: c.ctx });
  const market = await db.markets.get(c.params.id);
  if (!market) throw notFound();
  const locales = await db.locales.list({ where: { market_id: market.id } });
  return c.json({ market, locales });
});

module.exports = app.handler();

What you get:

  • Literal-first matching — /markets/defaults wins over /markets/{id}, regardless of registration order.
  • A clean per-request context — c.params, c.query, c.body (parsed), c.tenant, c.header(name), c.log(msg), c.json(data, status).
  • Error mapping — throw HttpError / notFound() / badRequest() / forbidden() / conflict() for explicit statuses; database constraint violations get the status they deserve (see below); a data-client permission refusal is a 403 (not_permitted); anything else is logged and answered as 500 {"error":"internal error","code":"internal_error"} — the thrown message never reaches the caller.
  • A health route — GET / answers with the App identity, status and the registered routes (disable with createApp({ health: false })).
  • 404/405 handling — unknown paths list the available routes; known paths with a wrong method answer 405.

CRUD in one line

mountCrud wires an entity to the standard five REST routes with filtering and pagination built in:

mountCrud(app, db.markets, { path: '/markets', columns: ENTITIES.markets.columns });
// GET    /markets            list — ?currency=EUR&limit=50&offset=0&order=code.asc
// POST   /markets            create
// GET    /markets/{id}       read
// PUT    /markets/{id}       update
// DELETE /markets/{id}       delete

Nested resources scope every operation to their parent:

mountCrud(app, db.locales, {
  path: '/markets/{marketId}/locales',
  columns: ENTITIES.locales.columns,
  parent: { param: 'marketId', column: 'market_id' },
});
// lists filter by market_id, creates inject it, and a locale belonging to a
// different market answers 404 — even if the request body claims otherwise.

Options: only: ['list', 'get'] restricts the mounted routes; defaultLimit / maxLimit tune pagination bounds (defaults 50 / 200).

List query keys are checked. A list accepts the columns in columns, the reserved keys limit, offset, order (exported as RESERVED_LIST_QUERY_KEYS) and whatever the App declares in queryParams. Anything else is refused rather than ignored — an ignored filter answers 200 with the unfiltered list:

GET /customers/segments?organization_id=o1
  -> 400 {"error":"unknown list filter 'organization_id' — …","code":"unknown_filter",
          "details":{"unknown":["organization_id"],"allowed":["code","id",…,"limit","offset","order"]}}

Parameters the App handles itself (a wrapping handler, middleware, a flag it tolerates) go in queryParams; they are accepted and never become filters:

mountCrud(app, db.accounts, {
  path: '/punchout/accounts',
  columns: ENTITIES.accounts.columns,
  queryParams: ['include_inactive'],
});

Without columns, only the reserved and declared keys are accepted. A list that reads its own parameters can also stay a hand-written route (only: ['get', 'create', 'update', 'delete'] plus app.get(base, …)).

The mock adapter answers a where on an undeclared column the way PostgREST does (400 bad_request), instead of an empty result.

A column filter is an exact value. A value that starts with a PostgREST operator (eq., in., not., …) is refused rather than matched literally — ?sku=in.(a,b) used to go out as sku=eq.in.(a,b) and answer 200 with zero rows:

GET /products?sku=in.(a,b)
  -> 400 {"error":"filter 'sku' carries an operator ('in.') — …","code":"unsupported_filter_operator",
          "details":{"parameter":"sku"}}

Value lists are not supported by the generic list; a route that needs one reads its own parameter (queryParams, or a hand-written list).

Constraint violations

A database constraint doing its job is not a server fault. The data adapters map PostgREST's error to the status the condition deserves and answer with a stable, generic message — the raw body (which names tables and constraints) is logged through the function's error() and recorded on the trace, never returned:

| condition | SQLSTATE | answer | message | |---|---|---|---| | duplicate value | 23505 / 23P01 | 409 | a record with this value already exists | | referenced parent missing (write) | 23503 | 400 | a referenced record does not exist | | dependent row blocks a delete | 23503 | 409 | this record is still referenced by other records | | required value missing | 23502 | 400 | a required value is missing | | check constraint | 23514 | 400 | a value is not allowed here | | bad uuid/number/timestamp | 22xxx (incl. 22P02) | 400 | a value in the request has the wrong format | | offset past the last row | PGRST103 | 200 | empty page with the requested limit/offset and the true total |

Responses carry a stable code alongside the message ({"error":"…","code":"unique_violation"}) so clients can branch without parsing prose. Anything unmapped keeps PostgREST's status when it is a client error and becomes 500 otherwise — with a generic message either way.

Catching these outside the router: import { isHttpError } from '@revenexx/app-sdk'. Use isHttpError(err), not instanceof — the package's two entrypoints are separate bundles, so each carries its own copy of the class.

Writes always answer with a record, or an error

create and update are typed Promise<Row> and now keep that promise — PostgREST reports a write that touched no row as 200 [], and handing the missing element back as if it were the record put an undefined into App code that only blew up at res.json() time, as a 500 with a raw Node message:

| condition | answer | code | message | |---|---|---|---| | update with a patch carrying no fields | 400 | empty_update | request body must contain at least one field to update | | update matched no row (deleted, or invisible under RLS) | 404 | not_found | not found | | create returned no representation | 500 | empty_representation | the record was created but could not be read back |

The empty-patch rule lives in the data client, not in mountCrud, so it holds for hand-written route handlers too. It is enforced before the request leaves the App — an empty patch costs no round trip.

These are HttpErrors: @revenexx/app-sdk/router turns them into the response automatically. An App that routes itself must honour err.status in its catch-all (isHttpError(err) ? err.status : 500), or it will report a 400 as a 500 no matter what the SDK raises.

Every refusal carries a code

From 1.0 every refusal the SDK raises — and every one an App raises through the helpers — carries a stable code beside its message. Branch on the code, never on the wording:

| code | status | raised by | |---|---|---| | not_found | 404 | notFound(), mountCrud for an unknown id or parent, update that matched no row | | bad_request | 400 | badRequest() | | forbidden | 403 | forbidden() | | conflict | 409 | conflict() | | not_permitted | 403 | an entity-client call the manifest does not grant | | empty_update | 400 | update / PUT with nothing to update | | empty_condition | 400 | updateIf with an empty where | | undefined_condition | 400 | a where entry left undefined, on any read or updateIf | | unknown_filter, unsupported_filter_operator, invalid_value | 400 | a mounted list's querystring | | missing_parameter | 400 | a mountCrud route without its path parameter | | no_route / method_not_allowed | 404 / 405 | the router | | internal_error | 500 | anything thrown that is not an HttpError | | unique_violation, exclusion_violation, foreign_key_violation, not_null_violation, check_violation, invalid_value, bad_request, data_error | 409 / 400 / 500 | the data plane (table above) |

A hand-built new HttpError(status, message) carries the code it is given, or none.

Every refusal body also repeats the message as message and the code as type (from 1.1). Those are the names the platform client (@revenexx/sdk) reads, so its exception carries the App's reason and code.

Reading another App's refusal

const { refusalOf } = require('@revenexx/app-sdk/router');

try {
  await revenexx.call('cost-centers', 'budgets.move', payload);
} catch (err) {
  const refusal = refusalOf(err); // { status, code, message, details, app } | null
  if (refusal?.code === 'no_active_budget') return c.json({ ... }, 409);
  throw err; // not a refusal: a crash or the far side being down
}

refusalOf reads this App's own HttpError and another App's refusal caught from the platform client alike. It returns null for anything that is not a refusal.

Who is acting

c.tenant says whose data a request is for. c.actor says whose hand is on it.

app.post('/procurement/pending-approvals/{id}/approve', async (c) => {
  const approval = await db.pending_approvals.get(c.params.id);
  // Compare two names, never two absences: a caller with no contact has none,
  // and an approval nobody is named on has none either, so `!==` alone would
  // let the two nulls agree.
  if (!c.actor.contact || approval.approver_contact_id !== c.actor.contact) {
    throw forbidden('this approval is not yours');
  }
  // …
});

Note what this does not do: gate on authenticated(). Read the next section before you write that line.

| field | is | |---|---| | user / email | the human the gateway authenticated, or null. null today | | contact / organization | the customers contact they were resolved to, or null | | permissions, holds(p) | what the tenant's role provider grants that contact | | trust | authenticated, asserted or none | | plane | contact, operator or system | | authenticated() | shorthand for trust === 'authenticated', and false for every caller today |

One plane produces anybody today, and it is asserted. A storefront call is made by a server holding the tenant API key on behalf of a customer: it asserts the person, the gateway resolves the name through the tenant's role provider, and trust is asserted. That is enough to stop a malicious end user and never a compromised BFF, the same boundary X-Revenexx-Market sits on, and the platform accepts it deliberately everywhere.

authenticated is not currently reachable, and you must not build authority on it. The value exists in the contract, c.actor reads it correctly, and the gateway does not produce it: a bearer token there is deliberately equivalent to an API key, so it proves the tenant rather than a person. The population on that plane is mostly machines — a theme site renders every request on the tenant's system service user, schedules fan out on the same subject, an operator working in a customer tenant acts on its operator service user — and none of them is somebody. authenticated() therefore answers false for every caller, which is the honest answer rather than a gap to work around. An app that refuses asserted refuses everyone.

plane follows from the same fact. contact is somebody the gateway resolved; system is nobody at all, which is a schedule, a webhook, a service token or any caller that named no one. operator would be an authenticated human with no contact and is likewise not produced today. Treating "no contact resolved" as "must be the platform" reads an unknown customer as an administrator, which is the mistake the field exists to prevent.

Every field comes from headers the gateway sets itself and strips from anything a caller sends, so none of this can be talked up from outside. Never take the acting person from the context token's claims — those describe the calling system, which in a theme site is the theme's service user — and never from the request body, which is the caller's own word about itself.

App settings

An App declares the knobs a merchant may turn in settings.json; the Cockpit renders them and Console stores the tenant's choices. c.settings() is how the App reads them back at runtime:

const app = createApp({
  name: 'orders',
  settings: { defaults: { auto_reserve: true, reservation_minutes: 30 } },
});

app.post('/orders', async (c) => {
  const s = await c.settings();
  if (s.auto_reserve) await reserve(c.body, s.reservation_minutes);
  // …
});

defaults mirrors the defaults in your settings.json. They type the resolved object and are what the App runs on if the settings service cannot be reached — they never override a value the merchant actually set.

Outside a handler, build the reader yourself. Create it once, at module scope: the cache lives on it.

const { createSettings } = require('@revenexx/app-sdk');
const settings = createSettings({ app: 'orders', defaults: { auto_reserve: true } });

const s = await settings.get(c);              // c = RouteContext, FnContext or { headers }
const full = await settings.load(c);          // { settings, masked, market, resolved }

Resolution

Values are resolved by the gateway (GET /v1/settings/apps/{app}?market=), not here. Per key, highest wins:

| | source | |---|---| | 1 | per-market override — only for settings declared "scope": "market", and only when the request carries a market | | 2 | the stored tenant value | | 3 | the default in settings.json | | 4 | the defaults you passed here — the offline fallback, and only where 1–3 produced nothing |

The market comes off the request's X-Revenexx-Market, so a market-scoped setting resolves for the market the call is for without being asked. Override it per read with c.settings({ market: 'fr' }).

Settings declared sensitive are never returned; their keys are listed in masked on load(), so an App can tell "not configured" from "not for you".

Failing open, and staying cheap

A settings read sits on the hot path of every request, so it must cost nothing and break nothing:

  • Fail open. An unreachable gateway, a timeout (2s), a missing tenant, an App that declares no settings — you get the last values that did resolve, or your declared defaults. settings() never throws. Pass onError (wire it to the function's error()) to keep the fallback visible.
  • Cache. Per (tenant, app, market), 60s by default, with concurrent reads sharing one in-flight request — a warm read is a Map lookup. The gateway keeps its own 30s cache and drops it when values change, so a Cockpit edit takes effect within about a minute. Tune with ttlMs or REVENEXX_SETTINGS_TTL_MS; a failed read is retried after 5s, not 60.

Testing your App

Handlers are plain functions over a context object, so tests don't need any infrastructure: build the app with a mock-adapter client, call app.handler() with a fake { req, res }, and assert on the captured JSON.

const handler = app.handler();
await handler({
  req: { method: 'GET', path: '/markets', headers: {}, query: {} },
  res: { json: (data, status = 200) => ({ data, status }) },
  log: console.log,
});

License

MIT