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

@thejob/users-client

v1.0.1

Published

Typed TypeScript/JavaScript client for the thejob-user-service HTTP API.

Readme

@thejob/users-client

Typed client for thejob-user-service. Published by the service that owns the data, so consumers never hand-roll HTTP calls or redeclare user types.

import { UsersClient } from '@thejob/users-client';

const users = new UsersClient({
  baseUrl: process.env.USERS_SERVICE_URL!,
  apiKey: process.env.USERS_SERVICE_API_KEY,  // backend-to-backend
  // token: userJwt,                          // user-scoped calls (/me)
});

const { results, total } = await users.listUsers(
  { countries: ['IN'], skills: ['python'] },
  { limit: 50 },
);
// -> ?marketingSubscribed=true&countries[0]=IN&skills[0]=python&limit=50

Methods

Twenty-nine methods, one per endpoint, plus two helpers: fileUrl builds a URL without requesting it, and getFile fetches the bytes that URL serves.

There is no countUsers: a count is listUsers(filter, { limit: 1 }) read off total, which is one line at the call site and keeps what total means visible.

There is no listUserPreviews either. The fuller per-user shape is the same query over the same users with more fields per row, so it is an option — listUsers(filter, { detailed: true }) — sitting beside withCompleteness and withMatchProfile. A second method implied a second question, and the two returned contradicting shapes because nothing forced them to agree.

| | | |---|---| | Read | listUsers · getUser · getUserBySlug · getMe | | Admin | updateRoles · deleteUser | | Profile sections | updateGeneralDetails · updateWorkExperiences · updateEducations · updateSkills · updateLanguages · updateCertifications · updateInterests · updateSocialAccounts · updateAdditionalInfo · updateMarketingConsent · updateNotificationPrefs · updateOnboarding | | Prefill | dismissPendingPrefill | | Files | uploadProfileImage · uploadResume · importResume · parseStoredResume · setPrimaryResume · deleteResume · fileUrl · getFile | | Internal | getNotifyProfile · health |

One method per PATCH /me/* section rather than a single updateMe(section, patch): the sections are not interchangeable, and two of them (marketing-consent, notification-prefs) carry opposite consent semantics that deserve to be named and documented separately.

Consent

listUsers applies marketingSubscribed: true by default. The server no longer forces this gate — it became an ordinary filter so one endpoint could serve every query shape — so this default is the only thing standing between a caller and mailing people who never opted in. An explicit marketingSubscribed: false really does return non-consenting users: legitimate for analytics, never for a send. Tagged @todo CONSENT-FILTER-ENFORCEMENT.

The two consent shapes are deliberately not one shape:

  • marketingConsent.subscribed is opt-IN, read as === true.
  • notificationPrefs is opt-OUT, read as !== false, so a user whose document predates the field reads as enabled.

Collapsing them is a compliance bug, not a tidy-up.

Design

One method per endpoint, never per use case. Methods are named for the resource and operation (listUsers, getUser), not for why a caller wants the data. A getMarketingRecipients would be the same mistake as an endpoint called /users/recipients: one caller's intent baked into a shared contract. Narrowing is the filter's job.

POST /users/recipients has been removed from the service. It was a narrower form of this same query behind a different verb and shape — the per-use-case endpoint proliferation the filter design exists to prevent. Everything it answered is a filter on listUsers.

A read is a GET. The filter is a structured document (token facets, numeric ranges, relative day-windows, a withinRadius geo group, a one-level or union) and it encodes into the query string, so no POST shadows the resource it reads.

Query strings are built by buildQuerySuffix from @thejob/util/query, shared with every service client. It drops what would encode to a parameter matching nothing — a blank value, an empty array, an untrimmed key — while keeping false and 0, which are meaningful.

Facets sit at the top level, next to paging, exactly as they appear in the URL — no filter[...] wrapper. One convention across every service client, and the query string mirrors the type.

buildQueryString also enforces the limits qs.parse fails silently past: it throws rather than emitting nesting deeper than QUERY_DEPTH (which the server would read as a literal "[key]") or an array longer than QUERY_ARRAY_LIMIT (which the server returns as an object keyed by index). The server parses with the same shared constants, so the two cannot drift.

total is the filter's count, not the page size. For a count alone, pass limit: 1 and read total.

Types

There is ONE user shape: User. It is @thejob/schema's canonical user — derived from UserSchema, so it cannot drift from what the schema validates — plus the two response-only fields (completeness, matchProfile). Every read returns it; a projection returns fewer fields, never different ones.

PaginatedResponse<T> is re-exported from @thejob/schema rather than redeclared, and ListUsersOptions extends its PaginationSchema fields rather than restating page / limit / sortBy / sortDirection.

UserFilter, NotifyProfile, MatchProfile, Resume and friends are the canonical definitions owned here and imported back by user-service, so there is exactly one definition of each.

This package once published five overlapping user types (UserListItem, UserDetail, UserProfile, plus two envelopes). They CONTRADICTED each other: name was a string in two and {first, last} in the third, and the same value was userId in two and shortId in the third. If a shape already exists in @thejob/schema, use it; do not restate it here.

They are named for what they are, never for what a caller wants them for. These types were once Recipient / RecipientFilter / RecipientPreview — marketing's word for a user it is about to mail. user-service has no recipients; it has users, and a filter that selects some of them. Naming the type after one consumer's use is the same mistake as an endpoint called /users/recipients, just moved into the type system, where every other consumer then inherits it.

pendingPrefill is seed values only: it populates a form's initial value and must never be read as a fallback for the user's actual value anywhere else.

Coverage

client.test.ts reads src/routes/users.ts, src/routes/files.ts and src/app.ts, collects every registered route, then drives a fake fetch through every method and asserts nothing is left over — in both directions, so a method pointing at a path the service does not register also fails.

Excluded: GET /, /login, /logout and /dashboard/* (the operations UI — session-cookie gated and same-origin, so an API-key client could not authenticate against them anyway).