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

patterna-sdk

v0.4.1

Published

Patterna client for backends, browsers and React Native: recommendations, interactions, events and catalog.

Readme

patterna-sdk

Version 0.4.1 (see CHANGELOG.md): the production engine reports engine_version: 0.3.1 on GET /v1/health (the contract version), and every field in this release is live on it. Works against engine 0.2 or later; older engines ignore the fields they do not know.

Install. The npm package is patterna-sdk (unscoped); pin the version:

npm install [email protected]

The source is the public repository payam-h/patterna-sdk, directory typescript/, tag v0.4.1.

TypeScript client for the Patterna API. One package for three places:

  • a backend (Node 22+, Bun, Deno, edge runtimes) holding the scoped API keys;
  • a browser or React Native / Expo app talking to your backend proxy, never to Patterna with a key;
  • tooling and tests, with the contract types generated from the OpenAPI contract (https://patterna.orven.am/docs/openapi.yaml).

ESM + CJS, TypeScript strict, zero runtime dependencies. Only fetch, AbortController, setTimeout and, for signing, WebCrypto. No Node built-ins in the core (eslint enforces it).

Fifteen-minute tour

In a checkout of typescript/:

npm ci
npm test          # vitest, mocked fetch, ~0.3 s
npm run check     # tsc --noEmit + eslint + vitest + tsup build

| File | What | |---|---| | src/client.ts | PatternaClient: every serve and ingest method, input checks | | src/http.ts | fetch wrapper: timeout, one retry for idempotent calls, typed errors, request ids | | src/consent.ts | the consent gate and the opaque-user-id check | | src/queue.ts | EventQueue: bounded, persistent offline outbox | | src/fallback.ts | withFallback | | src/signing.ts | canonical JSON and HMAC-SHA256 event signatures | | src/generated/types.ts | generated by make contracts; never edit |

The client

import { PatternaClient } from "patterna-sdk";

const patterna = new PatternaClient({
  baseUrl: "https://patterna-api.orven.am/v1",
  keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY },
  traceId: () => currentTraceId(),   // optional: X-Trace-Id on every call
});

| Method | Scope / key | Budget | Retried | |---|---|---|---| | recommend(req) | serve | 150 ms | never: each call logs an impression | | reportInteractions(requestId, interactions) | serve | 5 s | once | | ingestEvents(events) | ingest | 5 s | once (idempotent on event_id) | | upsertItems(items) | ingest | 5 s | once | | setItemStatus(kind, id, { status, published_at }) | ingest | 5 s | once | | setItemTags(kind, id, tags) | ingest | 5 s | once | | setUserMode(userId, mode) | admin key if set, else ingest | 5 s | once | | setUserProfile(userId, profile) | admin key if set, else ingest | 5 s | once | | deleteUser(userId) | admin key if set, else ingest | 5 s | once | | recommendWithFallback(req, fallback), recommendMany(reqs) | serve | 150 ms | never | | abandon(requestId, reason) | serve | 5 s | once | | getItem, deleteItem, listItems, itemsChangedSince | ingest | 5 s | once | | createSigningSecret and the admin methods below | admin | 30 s | idempotent ones once |

Budgets are per attempt and can be changed per client (timeouts: { recommendMs, ingestMs, adminMs }) or per call ({ timeoutMs, signal }). The single retry fires on 5xx and network errors only, after retryDelayMs (100 ms); timeouts are not retried because the budget is already spent.

Per-call options also take requestId (sent as x-request-id; minted otherwise), traceId (x-trace-id, overriding the client's traceId()) and, on recommend, tester: true (x-patterna-tester: 1).

Deadlines, request ids and abandonment

  • recommend sends x-deadline-ms: 120 ms by default (timeouts.recommendDeadlineMs, always below recommendMs; the engine's own default budget is 120 ms too), so the engine serves its own fallback in time instead of answering after the client gave up. A shorter timeoutMs shrinks it by the same margin: timeoutMs: 80 sends 50.
  • The client mints the request id, so it knows it even when no slate comes back. recommendWithFallback uses that: on a timeout, network error, 5xx or unreadable answer it sends POST /recommendations/{id}/abandon in the background, and the impression counts as a client fallback instead of a slate with zero taps. A 4xx (nothing was logged) is not abandoned. Failures of that background call go to onBackgroundError.
const r = await patterna.recommendWithFallback(req, () => newestArticleIds(), { traceId });
if (r.source === "patterna") render(r.value.items);
else { render(r.value); log.info({ request_id: r.requestId, code: r.error.code, abandoned: r.abandoned }); }

Call patterna.abandon(requestId) yourself when a slate arrived but the app showed something else.

shown: false: hide the block (engine 0.3)

When fewer items than the surface's min_slots pass the request's rules, the engine answers shown: false with no items. That slate already counts in its arm with zero taps, so it is never abandoned and never replaced by your list. Every result carries shown:

const r = await patterna.recommendWithFallback(req, () => newestArticleIds());
if (!r.shown) hideBlock();                       // source "patterna", no items, nothing abandoned
else render(r.source === "patterna" ? r.value.items : r.value);

abandon(requestId) on such a slate rejects with not_shown without sending when this client served it; otherwise the engine answers 409 not_shown. withFallback results carry shown too.

Control lists, communities and fills (engine 0.3)

  • context.control_item_ids (at most 50): your own editorial list for this page (the article's "pairs with" products). In the control arm it is the slate, in this order (control_source: "request"); in treatment it is what short slates are filled from. Without it the control slate comes from the surface's fallback.by_anchor for the anchor ("by_anchor") or its own list ("surface").
  • context.member_of (at most 500): community ids the user is in, never offered. Meant for non_profiled requests, which have no identity to look membership up by.
  • fill_count on the slate: trailing slots filled from the editorial list because the ranked candidates ran out. The SDK marks those items fill: true (markFills).
  • model_versions is typed on Slate; engine 0.3 logs it on the impression (trace, export) and does not send it with the answer yet.

AI coach products (engine 0.3)

import { coachProductShown, coachProductTapped } from "patterna-sdk";

await patterna.ingestEvents([
  coachProductShown({ mode, userId, productId: "p_mag", conversationId, position: 0 }),
]);
// non_profiled: { mode: "non_profiled", sessionId, ... } and no userId

product_id is the catalog external_id, conversation_id your own opaque id (never message text), position the 0-based place among the answer's products. The helpers apply the consent rules (user id in personalized and cohort, session id only in non_profiled) and default event_id, occurred_at and source: "backend".

Signals: context, editorial attributes, activity events (engine round 5)

Everything the engine reads is listed at https://patterna.orven.am/docs/signals; these are the SDK's parts of it.

import { timeContext, TIER_FREE } from "patterna-sdk";

await patterna.recommend({
  surface: "home_articles", mode: "personalized", user_id,
  context: {
    locale: "nl",
    ...timeContext(new Date(), user.timeZone), // hour_bucket and weekday (0 = Monday) in the user's zone
    subscription_tier: TIER_FREE,              // "premium" or your own tier name (<= 32 chars)
    tenure_days: daysSinceSignup,
    region: "Noord-Holland",                   // one of the tenant's regions; never a location
    session_items: recentlyOpenedIds,          // most recent first, at most 20
  },
});

Without a time zone timeContext reads the device clock. Out-of-range values fail before the request leaves; the same keys are allowed (and checked) in an event's context. For free the engine applies the premium policy and the paywall cooldown; none of these is stored on the user.

Editorial attributes. editorial on an upsert is the typed form of attributes:

await patterna.upsertItems([{
  external_id: "p_magnesium", kind: "product", title: "Magnesium", locale: "nl",
  editorial: { format: "product", available: inStock, repurchase_days: 30, price_cents: 2495,
               currency: "EUR", image_url, url: shopUrl, product_type: "supplement" },
}]);

The engine reads format, duration_min/reading_time_min, difficulty, author, available (false is never served), expires_at, starts_at (events; strings in RFC 3339 or Date), repurchase_days, region and phase_fit; the panel shows price_cents, compare_at_cents, currency, image_url, url, product_type and categories. A typed key wins over the same key in attributes, which keeps any others. region is sent as the upsert's top-level region, the field the engine validates and ranks on.

Products from a Shopify store do not need this call: the jobs CLI syncs the public storefront (python -m patterna_jobs shopify-storefront --tenant <slug> --shop-url https://<shop>) and turns an orders export into co-purchase pairs (orders-csv --csv <file>). There is no admin API call for either; run them where the jobs run.

Activity events, each with the envelope fields of EventBase (mode, userId, sessionId, surface, requestId, occurredAt, eventId, source, context):

communityPostCreated({ mode, userId, postId, communityId });
communityReply({ mode, userId, postId, communityId });
communityReaction({ mode, userId, postId, communityId, reaction: "heart" });
paywallViewed({ mode, userId, itemId, requestId });           // requestId: the slate it came from
pushOpened({ mode, userId, itemId, notificationType: "weekly_digest" });
searchPerformed({ mode, sessionId, queryHash, resultCount, clickedItemId });
itemEngaged({ mode, userId, itemId, engagedSeconds: 42, maxScrollPercent: 80 });
itemCompleted({ mode, userId, itemId, kind: "document", collection: "workouts" });
orderPlaced({ mode, userId, orderIdHash, productIds, valueCents: 4990, currency: "EUR" });

Identity follows consent: personalized and cohort carry the user id (required) and the session id when given; non_profiled carries the session id only and a user id is refused. queryHash must be 16 to 128 lowercase hex characters (hash the query yourself, e.g. HMAC-SHA256 with a key you hold), so query text cannot travel by mistake. item_completed and order_placed are ranking events: signEvent them when they do not come from an ingest key.

Reasons. A slot the engine cannot truthfully explain says REASON_RECOMMENDED ("recommended", "Aanbevolen"/"Recommended"); REASON_KEYS lists every key. Treat an unknown key like recommended.

Flows. FlowConfig carries the generators time_fit and format_fit (silent: they move scores and never name a reason; GENERATOR_KINDS) and the filters exclude_dismissed_days, exclude_consumed_days, paywall_cooldown_days, repurchase_days, hide_unavailable and relevance_floor (type FlowFilters). validateFlowConfig checks a config as the engine does on save, plus unknown filter keys; createFlow, planFlow and applyFlow run it first.

Several surfaces in one call

recommendMany([{ request, fallback }, ...]) runs the requests in parallel; each has the same budget from the same start, so Home's articles and products cost one budget, not two. Each falls back and is abandoned on its own and is its own impression and ledger row. Use it when the placements are separate on screen; one surface with mixed candidate_kinds gives one list and one ledger row you cannot split later.

Interactions: kind, re-sorting and split blocks

  • kind is required for anything that is not a document. For slates this client served, an interaction without kind on a community, product, event or tip throws ValidationError before sending. interactionFor(slate, itemId, type) fills kind and position.
  • displayed_position: when the app re-sorts on the device, keep position as the served slot and set displayed_position to where it was shown.
  • block: when one slate is split across UI blocks, name the block. A slate of 7 shown as a top slot plus an end carousel of six: request slots: 7, render items[0] on top and items.slice(1) in the carousel, and report with the served positions:
const s = r.value; // a Slate
const report = [
  interactionFor(s, s.items[0]!.item_id, "seen", { block: "top" })!,                 // position 0
  ...s.items.slice(1).map((it) => interactionFor(s, it.item_id, "seen", { block: "end_carousel" })!), // 1..6
];
await patterna.reportInteractions(s.request_id, report);

Report seen for carousel slots only when they scroll into view. One slate keeps one impression and one propensity per slot.

Consent: measured and assignment unit

  • measuredContext(context, analyticsConsent) sets measured: false keeps the request out of the ledger and the training data; the slate is still ranked and shown.
  • assignment_unit keeps one non-profiled person in one holdout arm across rotating sessions: assignment_unit: await assignmentUnit(experimentSecret, yourUserId) on the backend. The secret never leaves you; only the 64-hex shape is accepted, so a raw id cannot slip through. Same function and test vector in the Go and Python SDKs.

Catalog reconciliation

const item = await patterna.getItem("product", "42"); // ingest key; item.attributes, item.tags, item.status, item.enrichment
for await (const item of patterna.itemsChangedSince(lastRun)) reconcile(item);
await patterna.deleteItem("community", "community_42");

A missing item, or one stored under another kind, rejects with item_not_found (isApiError(err, ApiErrorCode.ItemNotFound)); an archived one comes back with status: "archived".

Admin (admin key)

For CI scripts that keep flow configs in your repository: listSurfaces, getSurface, setSurfaceStatus, setSurfaceTesters, listFlows, getFlow, createFlow, activateFlow, rollbackFlow, planFlow, applyFlow(surface, config, { note, activate }), listExperiments, startExperiment, endExperiment, ledger, ledgerWeeks, exportLedger, exportStatus, createSigningSecret, rotateSigningSecret. applyFlow compares configs as canonical JSON: nothing happens when the active version matches. The Go SDK ships the same as a CLI (patterna, in the go/ module).

Engine 0.3:

  • applySurface(spec) / planSurface(spec) / updateSurface(key, spec) with a SurfaceSpec (slots, item_kinds, fallback: { kind, item_ids, by_anchor }, min_slots, device_resort, notes, exploration_share). applySurface patches only the fields that differ and returns { changes, applied }; the fallback is replaced as a whole. validateSurfaceSpec runs the engine's checks before anything is sent. The engine answers a surface PATCH with { key, ok }, so these calls (and setSurfaceStatus, setSurfaceTesters) read the surface back.
  • ledgerByBlock(q) (by=block): one row per surface and block; ledger({ block }) one block.
  • surfaceMetrics(key, { days, block }): daily rows with not_shown_share and client_abandoned_rate.
  • getSettings(), updateSettings(patch), setPhaseTags(on) (phase_fit in slates; off by default).
  • health(): { status, version, engine_version, ... }.

Errors

Every failure is a PatternaError with code, op, status, requestId, details and a transient getter. Subclasses: ApiError (the API answered; code is the API's, e.g. pii_rejected), TimeoutError, NetworkError, ConsentError, ValidationError.

ApiErrorCode names the codes callers act on; test them with isApiError(err, code):

| Code | Status | In withFallback / recommendWithFallback | |---|---|---| | no_candidates | 422 | fallback (nothing eligible) | | surface_off | 409 | fallback (the kill switch) | | unknown_surface | 404 | fallback, but a configuration bug: alert on it | | mode_conflict | 422 | rethrown | | mode_required | 422 | rethrown; setUserProfile without mode for a user Patterna has no row for | | not_shown | 409 | abandon on a shown: false slate: hide the block instead | | pii_rejected | 422 | rethrown | | unknown_request | 404 | abandon, reportInteractions | | unknown_item | 422 | reportInteractions: an item that was not in the slate served for that request (details.unknown_item_ids) | | item_not_found | 404 | getItem, deleteItem, setItemStatus, setItemTags: no item with that id and kind |

try {
  await patterna.ingestEvents(batch);
} catch (err) {
  if (err instanceof ApiError) log.warn({ op: err.op, code: err.code, request_id: err.requestId });
  else throw err;
}

Consent gate

  • recommend throws ConsentError (consent_required) when mode is missing or unknown.
  • A non_profiled request must carry a session_id and no user_id; the type forbids it and the runtime throws ConsentError (consent_violation) for untyped callers. Nothing is sent.
  • User ids must be the random per-tenant id you mint. Emails and identity-provider subjects (auth0|...) are refused with ValidationError everywhere a user id goes; the id is never put in the error message.
  • Event context accepts only locale, app_version, platform, hour_bucket.

Fallback

import { withFallback } from "patterna-sdk";

const r = await withFallback(
  () => patterna.recommend({ surface: "home_articles", mode, user_id, context: { locale: "nl" } }),
  () => newestArticleIds(),            // your current query
  { onFallback: (e) => metrics.inc("patterna_fallback", { code: e.code }) },
);
const ids = r.source === "patterna" ? r.value.items.map((i) => i.item_id) : r.value;

By default it falls back on any SDK error except ConsentError, ValidationError, pii_rejected, mode_conflict and mode_required, which are bugs in the caller (a consent bug is a privacy bug) and are rethrown. Prefer client.recommendWithFallback, which also reports abandoned slates. { on: "timeout" } narrows it to timeouts. Report interactions only when r.source === "patterna".

Signing

import { signEvent } from "patterna-sdk";
const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!);

Signature = lowercase hex HMAC-SHA256 over the canonical JSON of the event without signature: keys sorted, no whitespace, undefined dropped, U+2028/U+2029 escaped. The key is the UTF-8 bytes of the secret string exactly as the API returned it (createSigningSecret, the panel); never hex-decode it. The Go and Python SDKs produce the same bytes; the suites share the vector 8baf7299…aac9 with key test-signing-key. Sign on a backend; a secret in an app bundle is a public secret. On React Native without crypto.subtle, pass a SubtleCrypto implementation as the third argument.

Example: backend proxy

The app never holds a Patterna key. Your backend authenticates the user, swaps the account id for the Patterna id it minted, applies the user's consent, and forwards. Paths mirror Patterna's so the same SDK works on both sides.

import { Hono } from "hono";
import { PatternaClient, withFallback, ValidationError, ConsentError, type Event } from "patterna-sdk";

const patterna = new PatternaClient({
  baseUrl: process.env.PATTERNA_URL!,
  keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY },
});
const app = new Hono<{ Variables: { account: Account } }>();
app.use("/patterna/*", requireSession); // sets c.var.account

app.post("/patterna/recommendations", async (c) => {
  const body = await c.req.json();
  const { mode, patternaId } = await consentFor(c.var.account); // your table; missing = non_profiled
  const req =
    mode === "non_profiled"
      ? { surface: body.surface, mode, session_id: body.session_id, context: body.context }
      : { surface: body.surface, mode, user_id: patternaId, context: body.context };
  // fallbackSlate(): your current list as a Slate with `fallback: true` and an empty request_id,
  // so the app renders one shape and skips reporting interactions for it.
  const r = await withFallback(() => patterna.recommend(req), () => fallbackSlate(body.surface));
  return c.json(r.value);
});

app.post("/patterna/events", async (c) => {
  const { events } = (await c.req.json()) as { events: Event[] };
  const { mode, patternaId } = await consentFor(c.var.account);
  // Never trust identity from the client: set it here.
  const owned = events.map((e) =>
    mode === "non_profiled" ? { ...e, user_id: undefined, source: "backend" as const }
                            : { ...e, user_id: patternaId, session_id: undefined, source: "backend" as const });
  try {
    return c.json(await patterna.ingestEvents(owned), 202);
  } catch (err) {
    if (err instanceof ValidationError || err instanceof ConsentError) return c.json({ code: err.code }, 400);
    throw err;
  }
});

Example: Expo app

import AsyncStorage from "@react-native-async-storage/async-storage";
import "react-native-get-random-values"; // crypto.getRandomValues for newSessionId on Hermes
import { AppState } from "react-native";
import { EventQueue, PatternaClient, newSessionId, withFallback } from "patterna-sdk";

// Talks to YOUR backend proxy above, authenticated as the signed-in user. No Patterna key here.
const patterna = new PatternaClient({
  baseUrl: `${config.apiUrl}/patterna`,
  headers: async () => ({ authorization: `Bearer ${(await getValidTokens())?.accessToken ?? ""}` }),
  timeouts: { recommendMs: 400 }, // a phone round trip to your proxy, which keeps its own 150 ms
});

export const outbox = new EventQueue({
  client: patterna,
  storage: AsyncStorage,         // persist / restore across app launches
  maxSize: 500,
  onDrop: (reason, n) => track("patterna_events_dropped", { reason, n }),
});
let sessionId = newSessionId();  // rotate per launch

export async function onConsentLoaded(mode: "personalized" | "cohort" | "non_profiled") {
  await outbox.setMode(mode);    // nothing is queued before this; non_profiled purges named events
}

AppState.addEventListener("change", (s) => {
  if (s === "active") sessionId = newSessionId();
  else void outbox.flush();      // send before the OS suspends the app
});

export async function homeArticles(fallbackIds: string[]) {
  const r = await withFallback(
    () => patterna.recommend({ surface: "home_articles", mode: "non_profiled", session_id: sessionId, context: { locale: "nl", platform: "ios" } }),
    () => fallbackIds,
  );
  return r;
}

export function seen(requestId: string, itemId: string, position: number) {
  return outbox.enqueue({
    event_id: `${requestId}:${itemId}:seen`,  // idempotency key: one seen per slot
    type: "rec_seen", occurred_at: new Date().toISOString(), source: "sdk",
    session_id: sessionId, request_id: requestId, surface: "home_articles",
    props: { item_id: itemId, position },
  });
}

The proxy sets the real identity, so the app sends session_id only and stays correct whatever the user's mode is.

Status

Built: the full serve and ingest client, the admin methods, client-minted request ids with abandonment, the server deadline header, trace ids, typed errors with the API code and request id, per-call timeouts (150 ms / 5 s / 30 s), one retry for idempotent calls, the consent gate in types and at runtime, opaque-user-id checks, withFallback, EventQueue (bounded, persistent via an injected AsyncStorage-shaped store, consent-aware, idempotent restore), HMAC signing with a cross-language vector, assignment units, session ids, and engine 0.3: control lists, member_of, control_source, fills, shown on every result, mode_required and not_shown, coach product events, surface apply, block ledger rows, surface metrics, phase tags and health; and engine round 5: the coarse request context with timeContext, typed editorial attributes, nine activity-event builders, the recommended reason key and validateFlowConfig with time_fit, format_fit and the six new filters. 185 tests with mocked fetch and a small in-memory engine. ESM + CJS + d.ts via tsup.

Deliberately left out:

  • Admin endpoints beyond surfaces, flows, experiments, ledger, metrics, phase tags and the signing secret (keys, curation, the other settings): the panel owns them.
  • A circuit breaker: backends that need one use the Go SDK; in an app withFallback and the per-call budget are enough.
  • No persistent queue for abandonments: they are fire-and-forget promises. In a backend that shuts down, a few may be lost; the Go SDK drains its queue on Close.

Settled since 0.1: the user endpoints (/users/{id}/mode, /users/{id}/profile, DELETE /users/{id}) accept the ingest key (the SDK uses the admin key when configured, else the ingest key), and the engine verifies signatures over the canonical form above, as the events contract describes (https://patterna.orven.am/docs/reference-event-types).