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

@catalyst-cloud/sdk

v0.11.2

Published

Keep a live local copy of your Linear and GitHub project data — pushed in real time, without polling rate limits or webhook tunnels.

Readme

@catalyst-cloud/sdk

Keep a live local copy of your Linear and GitHub project data — pushed to you in real time, without polling rate limits or webhook tunnels.

When you run coding agents at scale — dozens or more working in parallel — they all need the same live view of your Linear and GitHub state to coordinate, and you quickly hit the rate limits of the very systems your team depends on. catalyst-cloud is a service that mirrors your Linear + GitHub state once and pushes every change out to your fleet; this SDK is how your code subscribes to that stream and keeps a local copy current.

Why

  • Real-time updates, no tunnels. catalyst-cloud is the single webhook subscriber for your Linear and GitHub. Changes are pushed to you over an outbound connection — so you never stand up a public endpoint, smee/ngrok tunnel, or webhook gateway just to hear about an update locally.
  • One subscriber, not N. Your whole fleet reads from the shared mirror instead of each agent polling Linear and GitHub directly — so a large fleet doesn't multiply load against those rate limits.
  • A local copy, not just a feed. You don't get a firehose of events to babysit — you get your data, kept current, that you can query locally.

Coverage

  • Linear — issues, projects, cycles, initiatives, comments, labels, history, and relations. Mature.
  • GitHub — pull requests, checks, commit statuses, and reviews. Maturing.
  • Knowledge base — thoughts / memories. On the roadmap.

Requirements

A catalyst-cloud account and an auth token. (In the browser, a same-origin session cookie is used instead — no token in the page.)

npm install @catalyst-cloud/sdk

Today, and where this is going

  • Today — the SDK is the live-sync client. It keeps your local store current: it manages the connection, replays anything you missed while disconnected, and recovers automatically. You provide the storage (any SQLite, OPFS in the browser, or in-memory) and apply each change.
  • Coming — a fully managed local replica: a strongly-typed SQLite database you read directly via Drizzle ORM, with the syncing handled for you. The typed read layer already exists; we're folding it into the SDK so the database is something you read, not something you assemble.

Usage

Backend / agent host

Authenticate with a token and land every change into a local SQLite replica. On the first run (or after a long disconnect) the SDK asks you to re-seed from a snapshot; after that you just apply each pushed change.

import { Database } from "bun:sqlite"; // Node: better-sqlite3 with the same calls
import { LiveSyncClient } from "@catalyst-cloud/sdk";

const db = new Database("replica.sqlite");
db.run("CREATE TABLE IF NOT EXISTS issues (id TEXT PRIMARY KEY, row TEXT, seq INTEGER)");
db.run("CREATE TABLE IF NOT EXISTS sync_state (k TEXT PRIMARY KEY, cursor INTEGER)");

const client = new LiveSyncClient({
  baseUrl: process.env.CATALYST_CLOUD_BASE_URL!,
  accountId: "tenant-0",
  auth: { kind: "token", token: process.env.CATALYST_CLOUD_TOKEN! },

  // Full re-seed: fetch the current snapshot and rewrite the replica; resolve to the fresh cursor.
  reseed: async () => {
    const snap = (await (
      await fetch(`${process.env.CATALYST_CLOUD_BASE_URL}/snapshot?account=tenant-0`, {
        headers: { authorization: `Bearer ${process.env.CATALYST_CLOUD_TOKEN}` },
      })
    ).json()) as { cursor: number; issues: { id: string }[] };
    const tx = db.transaction(() => {
      db.run("DELETE FROM issues");
      const ins = db.prepare("INSERT INTO issues (id, row, seq) VALUES (?, ?, ?)");
      for (const row of snap.issues) ins.run(row.id, JSON.stringify(row), snap.cursor);
      db.run(
        "INSERT INTO sync_state (k, cursor) VALUES ('replica', ?) ON CONFLICT(k) DO UPDATE SET cursor = excluded.cursor",
        [snap.cursor],
      );
    });
    tx();
    return snap.cursor;
  },

  getCursor: () =>
    (db.query("SELECT cursor FROM sync_state WHERE k = 'replica'").get() as
      | { cursor: number }
      | undefined)?.cursor ?? null,

  // Apply each pushed change to your local copy.
  onChange: (frame) => {
    if (frame.op === "delete") db.run("DELETE FROM issues WHERE id = ?", [frame.entityId]);
    else
      db.run(
        "INSERT INTO issues (id, row, seq) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET row = excluded.row, seq = excluded.seq",
        [frame.entityId, JSON.stringify(frame.row), frame.seq],
      );
    db.run("UPDATE sync_state SET cursor = ? WHERE k = 'replica'", [frame.seq]);
  },
});

await client.start(); // resolves only when stop() is called — keeps the process alive between changes

Browser

In the browser there's no token: the same-origin session cookie authenticates the connection, and the SDK never puts anything secret in the URL. Storage is yours (here, a Map; in a real app, OPFS / IndexedDB).

import { LiveSyncClient } from "@catalyst-cloud/sdk";

const issues = new Map<string, unknown>();
let cursor: number | null = null;

const client = new LiveSyncClient({
  baseUrl: "https://app.example.com/api/v1", // same origin as the page
  accountId: "tenant-0",
  auth: { kind: "cookie" },
  reseed: async () => {
    const snap = (await (await fetch("/api/v1/snapshot?account=tenant-0")).json()) as {
      cursor: number;
      issues: { id: string }[];
    };
    issues.clear();
    for (const row of snap.issues) issues.set(row.id, row);
    cursor = snap.cursor;
    return snap.cursor;
  },
  getCursor: () => cursor,
  onChange: (frame) => {
    if (frame.op === "delete") issues.delete(frame.entityId);
    else issues.set(frame.entityId, frame.row);
    cursor = frame.seq;
  },
  onStatus: (s) => setConnectionBadge(s), // "live" / "reconnecting" / …
});

void client.start(); // never await in the browser
// client.stop() on teardown

Requires a platform WebSocket (browser, Bun, or Node ≥22). On older Node, inject a wsFactory that wraps the ws package.

Browser — managed OPFS replica (/browser)

BrowserReplica is the browser twin of /node's CatalystReplica: an OPFS-persisted SQLite replica in a dedicated Web Worker, seeded from /snapshot in bounded streamed batches, kept live off the same change feed, and read through the same @catalyst-cloud/read-model views — no hand-written worker, apply, or seeding code in your app.

import {
  BrowserReplica,
  isBrowserReplicaSupported,
  type ReplicaStatus,
} from "@catalyst-cloud/sdk/browser";

let status: ReplicaStatus = "loading";

if (isBrowserReplicaSupported()) {
  const replica = new BrowserReplica(
    {
      onChanged: () => void refreshList(), // after the seed and after every applied delta drain
      onStatus: (s) => {
        status = s; // "loading" | "live" | "reconnecting" | "error" | "secondary" | …
        setBadge(s);
      },
    },
    {
      baseUrl: "/api/v1", // same-origin, cookie-authed; accountId optional
      // REQUIRED. The OPFS database is shared by every replica on the origin and a warm cursor skips
      // the snapshot, so without a fence a change of signed-in user serves the PREVIOUS user's rows.
      // Pass a stable per-user id from your session — e.g. the WorkOS `user.id`. It never leaves the
      // browser; it is only compared against the value stamped in the local DB, and a mismatch wipes
      // the replica and forces a re-seed.
      identity: session.user.id,
    },
  );
  // AWAIT it. start() suspends on the Web Lock before the worker exists, so a query issued against a
  // not-yet-started replica rejects with "replica client closed".
  await replica.start(); // resolves once seeded — or immediately, with status "secondary"

  // Another tab already owns the origin's replica. That is a normal outcome, not an error: this tab
  // should read via its usual fetch path and leave the local DB alone.
  if (status !== "secondary") {
    const rows = await replica.queryIssues(); // read-model IssueView[], served locally
  }
  // replica.close() on teardown — cooperatively releases the OPFS handles, then terminates the worker
}

status above is whatever your onStatus handler last recorded. start() REJECTS if the boot fails (bad origin, missing worker chunk, OPFS unavailable) — it does not resolve into a broken state, so a try/catch around it is where you surface the error to the user.

Built in, because browsers need them:

  • Single-owner election (Web Locks): OPFS SAHPool is single-connection-per-origin, so exactly one tab boots the replica; every other tab gets status "secondary" (a clean state, not an error) and should read via its normal fetch path.
  • Backpressure: live deltas are coalesced into batched, single-flight applies with a bounded buffer; a backlog too deep to replay escalates to a fresh snapshot instead of growing.

Peer dependency: install @sqlite.org/sqlite-wasm yourself — the SDK never bundles the wasm.

Bundlers: the worker is created with new Worker(new URL("./db.worker.js", import.meta.url), { type: "module" }), which Vite, webpack 5, and Rollup detect statically and split into its own chunk (nothing wasm-related touches your main bundle; /node consumers never see it at all). Notes:

  • Vite: works out of the box in build. In dev, if pre-bundling interferes with the worker URL, add optimizeDeps: { exclude: ["@catalyst-cloud/sdk"] }.

  • webpack 5 / Rollup: the new Worker(new URL(...)) syntax is supported natively (Rollup needs worker support in your config, e.g. @surma/rollup-plugin-off-main-thread or equivalent).

  • Exotic bundlers: pass createWorker in BrowserReplicaOptions and construct the worker however your toolchain requires. The worker module is published at the dedicated subpath @catalyst-cloud/sdk/browser/db-worker — that specifier is the supported entry point:

    // Vite
    import DbWorker from "@catalyst-cloud/sdk/browser/db-worker?worker";
    createWorker: () => new DbWorker();
    
    // webpack 5 / anything that resolves a bare specifier to a URL
    createWorker: () =>
      new Worker(
        new URL("@catalyst-cloud/sdk/browser/db-worker", import.meta.url),
        { type: "module" },
      );

    The worker is a side-effect module (it registers a message handler and exports nothing), so it is listed in the package's sideEffects array — do not configure your bundler to tree-shake it, or it will load and register nothing and every replica call will hang.

Tenant client — typed reads and writes over HTTP

createTenantClient is one implementation of every tenant read and write, so a CLI, a skill script, an MCP tool and your own code share it instead of each carrying a bearer, a base URL and a response parser. Every call resolves to a discriminated union on outcome — nothing throws for a server answer — and every agent.* write's path is resolved from the tenant's own contract (GET /api/v1/agent/contract, read once and cached under the document's own ETag policy), never a literal.

import { createTenantClient, stageIdForSlot, teamForTicket } from "@catalyst-cloud/sdk";

const client = createTenantClient({
  key: process.env.CATALYST_CLOUD_TOKEN!,        // an organization-tier key for agent.* writes
  baseUrl: "https://staging.catalystcloud.dev",
});

// A keyset-paged read: follow `nextCursor` (typed, read off X-Mirror-Next-Cursor) until it is null.
let after = undefined;
do {
  const page = await client.issues.list({ teamKey: "ENG", state: "active", limit: 100, after });
  if (page.outcome !== "ok") throw new Error(`${page.outcome}: ${"reason" in page ? page.reason : ""}`);
  for (const row of page.rows) console.log(row.identifier, row.title);
  after = page.nextCursor ?? undefined;
} while (after);

// A write as the app actor. The state id comes from the contract by SLOT, never by a display name.
const contract = await client.contract();
if (contract.outcome !== "ok") throw new Error(`contract: ${contract.outcome}`);
const team = teamForTicket(contract.doc, "ENG-12");
const stage = team.outcome === "ok" ? stageIdForSlot(team.team, "research") : team;
if (stage.outcome !== "ok") throw new Error(stage.outcome);   // "slot-unmapped" / "slot-stale" / "team-unknown"

const issue = await client.issues.get("ENG-12");
if (issue.outcome !== "ok") throw new Error(issue.outcome);
const moved = await client.agent.issueState({ issueId: issue.issue.id, stateId: stage.stateId });
switch (moved.outcome) {
  case "succeeded":     break;                                  // moved.attempts
  case "rate-limited":  /* moved.retryAfterSeconds may be null */ break;
  case "forbidden":     /* moved.required names a missing scope */ break;
  case "route-unknown": /* this tenant's contract lists no issue-state route */ break;
  default:              /* rejected | exhausted | failed | unauthorized | network | shape | http | stale | missing */ break;
}

The contract cache is in memory per client by default; pass contractCache: { get, set } to keep it on disk (the bundle does). fetch, now and timeoutMs are injectable so a test never opens a socket.

Durable event cache for node and Bun

@catalyst-cloud/sdk/events mirrors the tenant's exact durable Catalyst event backbone into an append-only local cache. It uses bounded HTTP replay with an idle backoff; local consumers tail files and never poll GitHub, Linear, or cloud tables. Start and stop it with the process that currently needs the tenant instead of installing another permanent daemon.

import { CatalystEventSync, tailCachedEvents } from "@catalyst-cloud/sdk/events";

const sync = new CatalystEventSync({
  baseUrl: "https://staging.catalystcloud.dev",
  auth: { kind: "token", token: process.env.CATALYST_CLOUD_TOKEN! },
  tenantId: "tenant-1",
});
const running = sync.start();
for await (const event of tailCachedEvents({ tenantId: "tenant-1", signal })) {
  console.log(event.type, event.sequence);
}
await sync.stop();
await running;

The default path is $XDG_STATE_HOME/catalyst/events/<tenant>/backbone/, falling back to ~/.local/state/catalyst/events/<tenant>/backbone/. Daily YYYY-MM-DD[-NNN].jsonl segments contain the cloud's CatalystEvent envelope unchanged. cursor.json is owned by the one sync writer; readers keep their own offsets elsewhere and do not write into this directory. The stable replay identity is (tenantId, sequence, eventId).

The writer fsyncs appended records before atomically advancing its cursor. On restart it truncates an incomplete final line and derives the resume cursor from the last complete record, making a crash between append and checkpoint safe. Server archive gaps and reads behind the local retention floor raise EventHistoryGapError with the head, suggested resume point, and reason; the SDK does not silently skip history. New caches bootstrap at the current backbone head. Closed daily segments are retained for seven days or until the cache reaches 256 MiB, whichever happens first. The active segment rotates before the same byte bound, so it cannot grow without limit; retainDays and maxCacheBytes configure these bounds.

This API only reads the durable backbone. It does not publish events or treat raw provider events, the legacy coordination feed, or analytical samples as execution or billing authority. Use the existing authorized cloud operations to report outcomes and changes.

API

| Export | What it is | | --- | --- | | LiveSyncClient | The client — new LiveSyncClient(opts), then start() / stop(). | | LiveSyncClientOptions | The options shape (auth, reseed, getCursor, onChange, onStatus, backoff, wsFactory, log). | | AuthStrategy | { kind: "token"; token } (backend) or { kind: "cookie" } (browser). | | LiveSyncStatus | "connecting" · "live" · "reconnecting" · "resyncing" · "error" · "stopped". | | ChangeFrame · EntityName · ChangeOp | The change shape + the entity/op contract. | | createTenantClient | The typed HTTP client — contract(), me(), issues.list/get, pulls.list/get, projects.list, agent.* (issueState, issueLabel, issueComment, issueCreate, reaction, attachment, attachments, session, ask, askAccept, projectRepositoryRegister, projectRepositoryRemove). | | TenantContract · routeByName · teamByKey · teamForTicket · stageIdForSlot · labelIdFor | The contract document's shape and the pure accessors over it, each returning a typed miss rather than throwing. | | TenantClientFailure · PageCursor · pageCursor · memoryContractCache | The shared failure arms, the opaque page token, and the default contract cache store. | | CatalystEventSync · readCachedEvents · tailCachedEvents · EventHistoryGapError | Node/Bun durable-backbone replay, local cache readers, and explicit history-gap handling from @catalyst-cloud/sdk/events. |

start() resolves only when stop() is called. On a backend, await it to keep the process alive; in a browser, never await it — just call stop() on teardown.

License

MIT © Coalesce Labs — see LICENSE.