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

@zigbase/client

v0.3.0

Published

Official TypeScript client for ZigBase

Readme

@zigbase/client

Official TypeScript client for ZigBase. Zero dependencies; runs in browsers, Node 18+, Bun, Deno, and edge runtimes.

For the full guide (framework bindings, security notes, known limitations) see docs/typescript-sdk.md.

Install

npm install @zigbase/client

Published on npm as @zigbase/client. Three tiers — the base dynamic client (this package), the comptime-generated typed client (zig build gen-client), and runtime introspection (npx @zigbase/typegen). See the TypeScript SDK docs.

New in 0.3.0 — full-text search + structured vector queries on list reads, multi-tenant account scoping (accountId, client.withAccount(id), accounts.activate), per-record getAbilities(id), analytics read APIs (client.analytics.events/.rollup), verified sender management (client.senders.list/create/verify), realtime custom topics (subscribeTopic/unsubscribeTopic), and typed sort + a native in where-DSL operator in generated clients. Requires ZigBase >= 0.9.0 for the new surfaces; senders and the __features signal require >= 0.10.0. See the TypeScript SDK docs for the full write-up.

Quick start

import { createClient } from "@zigbase/client";

const zb = createClient("http://127.0.0.1:8090");

// Authenticate (saves the token + record into the auth store)
await zb.collection("users").authWithPassword("[email protected]", "secret");

// Read records — pass a type parameter so dynamic fields are typed (see below)
import type { ZbRecord } from "@zigbase/client";
interface Post extends ZbRecord { title: string; status: string }
const posts = await zb.collection("posts").getList<Post>(1, 30);
console.log(posts.items[0]?.title);

// Call any endpoint directly
const health = await zb.send("GET", "/api/health");

Auth + stores

Three stores ship in the box:

  • MemoryAuthStore — the default. SSR-safe, never touches the DOM.
  • LocalAuthStore — persists to browser localStorage; survives reloads.
  • CookieAuthStoreexportToCookie() / loadFromCookie() for SSR token handoff.
import { createClient, LocalAuthStore } from "@zigbase/client";

const zb = createClient(url, { authStore: new LocalAuthStore() });

await zb.collection("users").authWithPassword("[email protected]", "secret");
zb.authStore.isValid; // local JWT-exp check (UX only — see security note)
zb.authStore.record;  // the authenticated record
await zb.collection("users").authRefresh();
await zb.collection("users").logout(); // clears the store

// react to login / logout / refresh anywhere
const off = zb.authStore.onChange((token, record) => {
  console.log("auth changed", record?.id ?? "(signed out)");
});

OAuth2 (Authorization-Code + PKCE)

import { createPkceChallenge, randomState } from "@zigbase/client";

const { providers } = await zb.collection("users").listAuthProviders();
const { verifier, challenge } = await createPkceChallenge();
const state = randomState();
// 1. redirect to the provider authorize URL with `challenge` + `state`
// 2. on the callback, exchange the code:
await zb.collection("users").authWithOAuth2({
  provider: "github",
  code,
  codeVerifier: verifier,
  redirectUrl: "https://app.example.com/callback",
  state,
});

Security. isValid decodes the JWT exp client-side — it is a UX/expiry hint only, never an authorization decision (the server authorizes every request). LocalAuthStore tokens live in localStorage and are therefore reachable by XSS — a standard SPA tradeoff; for SSR or stricter setups prefer CookieAuthStore with secure: true (and an HttpOnly cookie, which only the server can set — JS cannot).

Records

The base SDK is dynamically typed: a plain ZbRecord has id: string and every other field typed unknown. Pass a type parameter (getOne<Post>(), getList<Post>(), …) so reads return your shape — otherwise reading post.title won't compile. You can also cast. Declare your row type by extending ZbRecord (it carries the id plus the index signature the cursor methods require):

import type { ZbRecord } from "@zigbase/client";

interface Post extends ZbRecord {
  title: string;
  status: "draft" | "published";
  author: string;
  cover: string;
}

const posts = zb.collection("posts");

const page = await posts.getList<Post>(1, 30, {
  filter: "status = 'published'",
  sort: "-created,title",
  expand: "author",
});
page.items[0]?.title; // typed as string

const post = await posts.getOne<Post>("REC_ID", { expand: "author" });
const first = await posts.getFirstListItem<Post>("status = 'draft'"); // throws 404 if none
const created = await posts.create<Post>({ title: "Hi", status: "draft", author: "u1" });
const updated = await posts.update<Post>(created.id, { title: "Edited" });
await posts.delete(created.id);

Safe filters

Use the filter tagged template to interpolate user input without injection risk. Strings are always single-quoted and escaped against the server lexer (', \, and newline/tab/CR are backslash-escaped), numbers/booleans inline, and Date becomes an ISO string. Any string is representable — including values containing both ' and ":

import { filter } from "@zigbase/client";

const q = userInput; // even `' || 1=1 --` or `he said "hi" to O'Brien` is safely quoted
await posts.getList<Post>(1, 30, {
  filter: filter`status = ${"published"} && author ~ ${q}`,
});

Injection safety. The closing quote can only appear escaped, so an interpolated value can never break out of its literal — user input is always an inert single token.

Pagination — offset + cursor

// Offset: random page access + exact totals.
const p = await posts.getList<Post>(2, 30);
p.totalItems; // total across all pages
p.totalPages;

// Cursor (keyset): stable under inserts, no deep-offset cost.
let c = await posts.getPage<Post>({ limit: 20, sort: "-created" });
render(c.items);
while (c.hasNext && c.nextCursor) {
  c = await posts.getPage<Post>({ limit: 20, sort: "-created", cursor: c.nextCursor });
  render(c.items);
}

// Iterate every matching record (stable even while rows are inserted):
for await (const post of posts.iterate<Post>({ sort: "-created" })) {
  handle(post);
}
const all = await posts.getFullList<Post>({ filter: "status = 'published'" });

Which one? Use offset (getList) when you need jump-to-page-N or a total count. Use cursor (getPage / iterate / getFullList) for stable feeds and infinite scroll where deep offsets get slow. Cursor pagination is native server-side keyset: the server mints an opaque nextCursor/prevCursor token that the client just forwards back — there is no client-side keyset predicate or id tiebreaker to reason about. Totals are skipped by default (cheap); pass withTotal: true to a getPage call to include totalItems.

File uploads & URLs

A create/update body containing a File/Blob (or an array of them) is sent as multipart automatically — no special method:

// Send a file from an <input type=file> — multipart is auto-detected:
const rec = await posts.create<Post>({
  title: "Hi",
  status: "draft",
  author: "u1",
  cover: fileInput.files![0]!,
} as Record<string, unknown>);

// Build a URL to the stored file (cover is typed string on Post):
const url = zb.files.getUrl(rec, rec.cover, { thumb: "100x100" });

// Protected files: mint a short-lived access token for <img src> / emails:
const token = await zb.files.getToken();
const protectedUrl = zb.files.getUrl(rec, rec.cover, { token });

Runtime introspection — typegen for black-box backends

If you consume a ZigBase backend as a black box — no Zig source, no build.zig — use the typegen subcommand built into the server binary to generate the same typed client. Run myserver typegen --data-dir ./zb_data --out src/zbase.gen.ts (offline, reads a provisioned data directory) or supply --url / --admin-email / --admin-password to introspect a live instance. The generated db / realtime / files surface is identical to the comptime generator's output, with one difference: rpc.* is not emitted (custom routes are not introspectable at runtime). If you need typed RPC, have the Zig source, and use the comptime generator instead. The subcommand requires the server binary to be built with .enable_typegen = true. See Runtime introspection (zigbase typegen) in the TypeScript SDK docs for the full flag reference and CI staleness-gate recipe.

Typed client — @zigbase/client/typed

@zigbase/client/typed is the generic typed core that a generated zbase.gen.ts file instantiates into a fully type-safe, schema-aware client. The generator is coming in SP2.1b; for now, the hand-authored fixture test/fixtures/blog.gen.ts serves as the reference pattern for what the generator will emit.

The subpath exports runtime factories (makeRecordService, makeTypedRealtime, makeTypedFiles) and the where-DSL compiler + fluent builder. A generated client imports from this subpath, declares concrete record types and per-field metadata, then builds a BlogClient-style wrapper that exposes an ergonomic typed surface:

// In a consumer repo (generated file):
import { createClient as baseCreateClient } from "@zigbase/client";
import { withRealtime } from "@zigbase/client/realtime";
import {
  makeRecordService,
  makeTypedRealtime,
  makeTypedFiles,
  type CollectionMeta,
  type WithExpand,
} from "@zigbase/client/typed";

// Hand-declare (or let the generator emit) per-collection metadata:
const postsMeta: CollectionMeta = {
  name: "posts",
  fields: { title: { type: "text" }, status: { type: "select" } /* … */ },
  fileFields: ["cover"],
  expandable: ["author", "tags"],
  isAuth: false,
};

// Build the typed service (compiles `where` → SP1 filter strings):
const base = withRealtime(baseCreateClient(url));
const posts = makeRecordService(base, postsMeta) as unknown as PostsService;

// The generated interface narrows every call:
const page = await posts.getList({ where: { status: "published" }, sort: "-created" });
// page.items[0]?.title — typed string

Until the Zig generator lands (SP2.1b), see test/fixtures/blog.gen.ts for the full worked example of what a generated client looks like, including expand-narrowed getOne, typed create/ update payloads, fluent filter builder, and realtime/files surfaces.

Typed RPC — zb.rpc.*

When a generated zbase.gen.ts declares typed routes (via the Zig server's .routes config), the generated client exposes them under zb.rpc.<name>(params?, input?, opts?):

  • params object is present IFF the route path contains :param segments (e.g. { id: string }).
  • input argument is present IFF the route's Input type is non-void (POST/PUT/PATCH bodies).
  • GET/DELETE routes pass non-params arguments as query string; POST/PUT/PATCH routes pass them as the request body.
  • Throws a ZigbaseError on non-2xx — same as the rest of the client.
  • The last optional opts argument accepts SendOptions (signal, requestKey, headers).

golfsim example — the golfsim app declares four typed routes, and zig build gen-client emits:

import { createClient, type Booking } from "./clients/typescript/zbase.gen.js";

const zb = createClient(url, { WebSocket: globalThis.WebSocket });
await zb.db.users.authWithPassword("[email protected]", "pass");

// POST /api/bookings/:id/confirm — params object; output is unknown (std.json.Value)
const booking = { id: "BOOKING_ID" };
const confirmed = await zb.rpc.bookingsConfirm({ id: booking.id }) as Booking;
console.log(confirmed.status); // "confirmed"

// GET /api/golfsim/health — no params; typed output (HealthOut)
const health = await zb.rpc.golfsimHealth();
console.log(health.status); // "ok"

The four golfsim RPC methods and their signatures:

| Method | Generated signature | | --- | --- | | bookingsConfirm | (params: { id: string }, opts?: SendOptions) => Promise<unknown> | | bookingsCancel | (params: { id: string }, opts?: SendOptions) => Promise<unknown> | | listingsAvailability | (params: { id: string }, opts?: SendOptions) => Promise<unknown> | | golfsimHealth | (opts?: SendOptions) => Promise<HealthOut> |

unknown outputs correspond to Zig std.json.Value return types — cast to your concrete interface for type-safe access.

Realtime + live store

Realtime lives behind a dedicated entry point, @zigbase/client/realtime. Opt in with withRealtime(client) — a REST-only app that never imports it doesn't bundle the realtime / live-store / filter-eval graph at all (it tree-shakes out, ~13 KB minified).

import { createClient } from "@zigbase/client";
import { withRealtime } from "@zigbase/client/realtime";

const zb = withRealtime(createClient("http://127.0.0.1:8787", { WebSocket }));
// `zb.realtime` is now available (and the client is otherwise unchanged).

Low-level subscriptions

const unsub = await zb.realtime.subscribe(
  "posts",
  (e) => {
    e.action; // "create" | "update" | "delete"
    e.record; // the record (a delete carries only { id })
  },
  { filter: "status = 'published'" },
);

await unsub(); // stop this callback; the socket closes when the last topic goes away

A single shared WebSocket multiplexes every topic, auto-reconnects with backoff, and re-auths from the auth store on login/logout/refresh. Anonymous subscriptions require a @public view rule on the collection (server-enforced).

High-level live store

zb.realtime.collection(name) returns live objects kept in sync as events arrive. You must call close() when done to release the realtime subscription and cache refs.

const live = zb.realtime.collection("posts");

// Live record — patched in place on update, `deleted` flips true on delete.
const post = await live.getOne("REC123");
post.subscribe(() => render(post.get()));
// ... later:
post.close(); // REQUIRED — releases the subscription + cache ref (idempotent)

// Live list — ordered items kept in sync; bind via the observable contract.
const list = await live.getList(1, 30, { sort: "-created" });
const unbind = list.subscribe(() => render(list.get()));
list.mode; // "precise" | "refetch" (see below)
// ... later:
unbind();
list.close(); // REQUIRED

list.mode is "precise" when the filter references only the record's own scalar fields (membership is evaluated locally with surgical insert/move/remove) and "refetch" when the filter traverses a relation or uses a macro (the list debounces a re-fetch of the query instead).

React binding

list.get() / list.items is a stable, mutated array, so subscribe and key your snapshot on list.version to force re-renders:

import { useSyncExternalStore, useEffect, useState } from "react";

function Feed({ zb }) {
  const [list, setList] = useState<LiveList | null>(null);

  useEffect(() => {
    let live: Awaited<ReturnType<typeof zb.realtime.collection>["getList"]>;
    let cancelled = false;
    zb.realtime.collection("posts").getList(1, 30, { sort: "-created" }).then((l) => {
      if (cancelled) l.close();
      else { live = l; setList(l); }
    });
    return () => { cancelled = true; live?.close(); }; // cleanup closes the list
  }, [zb]);

  const version = useSyncExternalStore(
    (cb) => (list ? list.subscribe(cb) : () => {}),
    () => list?.version ?? 0,
  );

  if (!list) return null;
  return <ul>{list.get().map((r) => <li key={r.id}>{String(r.get().title)}</li>)}</ul>;
}

Error handling

Every non-2xx response rejects with a ZigbaseError carrying status, message, url, and per-field validation errors in data:

import { isZigbaseError } from "@zigbase/client";

try {
  await posts.create<Post>({ title: "" } as Record<string, unknown>);
} catch (err) {
  if (isZigbaseError(err) && err.status === 400) {
    console.log(err.data.title?.message); // field-level error
  }
}

Field projection — fields

fields trims the response to the listed fields (server-side) and is honored on every read path: getList / getOne and the cursor engine (getPage / iterate / getFullList) and the live store (collection().getList / getPage / getOne seed fetches):

await posts.getFullList({ sort: "-created", fields: "id,title" });
for await (const p of posts.iterate({ fields: "id,slug" })) { /* ... */ }
const live = await zb.realtime.collection("posts").getList(1, 30, { fields: "id,title" });

Auto-cancellation — requestKey

Pass requestKey on any read/mutation (or send / fetch) for opt-in last-write-wins de-duplication: issuing a new request with a key aborts any in-flight request sharing that key. Without a key, nothing is auto-cancelled. Aborted requests reject with a DOMException whose name is "AbortError". Composes with your own signal (either aborts the request):

// As the user types, only the latest search survives:
const results = await posts.getList(1, 20, { filter, requestKey: "search" });

Escape hatch — send() and raw fetch()

send() calls any endpoint the typed surface doesn't cover, returning parsed JSON; the auth header, retries, and ZigbaseError mapping still apply:

const stats = await zb.send<{ users: number }>("GET", "/api/custom/stats", {
  query: { window: "7d" },
});
await zb.send("POST", "/api/custom/reindex", { body: { collection: "posts" } });

When you need the raw Response (binary/text bodies, custom headers, streaming), use zb.fetch(method, path, opts). It passes through query / body / headers / signal / requestKey and the auth header, but does not JSON-parse and does not throw on non-2xx — you get the Response as-is:

const res = await zb.fetch("GET", "/api/export.csv", { query: { format: "csv" } });
if (res.ok) console.log(res.headers.get("content-type"), await res.text());