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

@truenorth-it/dataverse-client

v1.0.9

Published

**Unlock your Dataverse data.** Your CRM data is trapped behind OData queries, Azure AD service principals, and Microsoft SDK complexity. Your frontend team just wants JSON. This package is the key.

Readme

@truenorth-it/dataverse-client

Unlock your Dataverse data. Your CRM data is trapped behind OData queries, Azure AD service principals, and Microsoft SDK complexity. Your frontend team just wants JSON. This package is the key.

A type-safe JavaScript/TypeScript client that turns your Dataverse instance into clean REST endpoints. Secure by architecture — every request is authenticated, authorised, and scoped to the logged-in user. No Microsoft dependencies in your frontend. No Azure tokens in the browser.

Works with any deployment — generate TypeScript types specific to your Dataverse org, or use it untyped.

Installation

npm install @truenorth-it/dataverse-client

Quick start (no types)

The SDK works out of the box without any type generation. Response data is Record<string, unknown>:

import { createClient } from "@truenorth-it/dataverse-client";

const client = createClient({
  baseUrl: "https://api.dataverse-contact.tnapps.co.uk",
  scope: "default", // scope segment in API URLs (e.g. /api/v2/default/me/case)
  getToken: () => getAccessTokenSilently(), // your Auth0 token provider
});

const result = await client.me.list("case", {
  select: ["title", "ticketnumber", "statuscode", "createdon"],
  top: 20,
  orderBy: "modifiedon:desc",
});

for (const record of result.data) {
  console.log(record.ticketnumber, record.title);
}

Why this exists

Your Dataverse data is locked behind APIs designed for platform consultants — not for frontend teams building customer portals. Between your React app and the data sit Azure AD client credentials, OData entity navigation, MSAL token management, and GUID-based lookups.

This client eliminates all of that:

| You write | Instead of | |---|---| | client.me.list("case") | OData queries, $filter, $expand, GUID joins | | getAccessTokenSilently() | MSAL, client credentials, service principal config | | Nothing — it's automatic | Custom row-level security middleware | | npx dataverse-client generate | Manually typing Dataverse schemas |

Stop building middleware. Start building your portal.

Types are optional

Generated types are a developer experience convenience, not a requirement. The SDK works identically with or without them — every method accepts an optional generic, and defaults to Record<string, unknown> when none is provided.

  • No types: works out of the box, no setup needed
  • With types: add <Case> (or any generated interface) to calls where you want autocomplete
  • Mix freely: type the tables you work with most, leave the rest untyped
  • Stale types are fine: if your schema changes and you don't regenerate, your code still runs — you just lose autocomplete for new fields
// ✅ All three of these work in the same file
const cases    = await client.me.list<Case>("case");  // typed
const projects = await client.me.list("project");              // untyped — still works
const contacts = await client.me.lookup<Contact>("contact", { search: "jane" }); // typed

Generate types for your API

For full TypeScript autocomplete and type safety, generate interfaces from your own API deployment:

npx dataverse-client generate --url https://api.dataverse-contact.tnapps.co.uk

This fetches the schema and choice values from your API (public endpoints, no auth required) and creates dataverse-tables.generated.ts with:

  • Typed interfaces for every table
  • Const enum objects for every choice (picklist) field

CLI options

| Flag | Default | Description | |------|---------|-------------| | --url, -u | (required) | Base URL of your API deployment | | --output, -o | ./dataverse-tables.generated.ts | Output file path | | --api-base | /api/v2 | API base path (before scope segment) | | --scope | default | API scope segment (inserted after api-base in URLs) | | --no-choices | false | Skip fetching choice values (no enums generated) |

Using generated types

import { createClient } from "@truenorth-it/dataverse-client";
import type { Case, Contact } from "./dataverse-tables.generated";

const client = createClient({
  baseUrl: "https://api.dataverse-contact.tnapps.co.uk",
  scope: "default",
  getToken: () => getAccessTokenSilently(),
});

// Fully typed — result.data is Case[]
const result = await client.me.list<Case>("case", {
  select: ["title", "ticketnumber", "statuscode", "createdon"],
  top: 20,
});

for (const c of result.data) {
  console.log(c.ticketnumber, c.title, c.statuscode_label);
  //          ^ string        ^ string  ^ "In Progress" etc.
}

Using choice enums

The generated file also includes const objects for choice (picklist) fields, so you can compare values without magic numbers:

import { createClient } from "@truenorth-it/dataverse-client";
import type { Case } from "./dataverse-tables.generated";
import { CaseStatuscode, CasePrioritycode } from "./dataverse-tables.generated";

const cases = await client.me.list<Case>("case", {
  filter: `statuscode eq ${CaseStatuscode.InProgress}`,
});

for (const c of cases.data) {
  if (c.statuscode === CaseStatuscode.OnHold) {
    console.log("Case is on hold:", c.title);
  }
  if (c.prioritycode === CasePrioritycode.High) {
    console.log("High priority:", c.title);
  }
}

Each choice const is also a type — use it for function signatures:

function handleStatus(status: CaseStatuscode) {
  // status is narrowed to 1 | 2 | 3 | 5 etc.
}

To skip choice generation (faster, interfaces only): npx dataverse-client generate --url ... --no-choices

CI / build integration

Add to your package.json to regenerate types when your schema changes:

{
  "scripts": {
    "generate:types": "dataverse-client generate --url $API_URL --output src/dataverse-types.ts",
    "prebuild": "npm run generate:types"
  }
}

Programmatic usage

The codegen function is also exported for use in custom build scripts:

import { generateTableTypes } from "@truenorth-it/dataverse-client";

const response = await fetch("https://api.dataverse-contact.tnapps.co.uk/api/v2/default/schema");
const schema = await response.json();
const tsSource = generateTableTypes(schema);
// Write tsSource to a file, pipe it, etc.

Usage with React + Auth0

import { useMemo } from "react";
import { useAuth0 } from "@auth0/auth0-react";
import { createClient } from "@truenorth-it/dataverse-client";

function useApiClient() {
  const { getAccessTokenSilently } = useAuth0();

  return useMemo(
    () =>
      createClient({
        baseUrl: import.meta.env.VITE_API_BASE_URL,
        scope: "default",
        getToken: () => getAccessTokenSilently(),
      }),
    [getAccessTokenSilently],
  );
}

Client configuration

createClient({
  baseUrl: string;                    // API deployment URL
  getToken: () => Promise<string>;    // Returns a Bearer token
  apiBase?: string;                   // Defaults to "/api/v2"
  scope?: string;                     // Defaults to "default" — inserted after apiBase in URLs
});

Access scopes

The client exposes three scopes, each backed by server-side authorization:

| Scope | Description | Operations | |-------|-------------|------------| | client.me | Your own records (contact-scoped) | list, get, update, lookup, create, whoami | | client.team | Records belonging to your team/account | list, get, update, lookup | | client.all | All records (requires elevated permissions) | list, get, update, lookup |

Operations

List records

const result = await client.me.list("case", {
  select: ["title", "ticketnumber", "statuscode", "prioritycode", "createdon"],
  top: 50,
  orderBy: "createdon:desc",
  filter: "statuscode eq 1",
});

// result.data — Record<string, unknown>[] (or Case[] if you pass a generic)
// result.page — { top, skip, next } — use fetchPage(page.next) for subsequent pages

for (const c of result.data) {
  console.log(c.ticketnumber, c.title);
  console.log(c.statuscode, c.statuscode_label);     // 1, "In Progress"
  console.log(c.prioritycode, c.prioritycode_label); // 2, "Normal"
}

With types: client.me.list<Case>("case", ...) — gives full autocomplete on result.data.

List activity records by subtype

Activity tables (caseactivities, contactactivities) support subtype filtering to retrieve specific activity types:

// List all phone calls for your cases
const phoneCalls = await client.me.list("caseactivities", {
  subtype: "phonecall",
  select: ["activityid", "subject", "activitytypecode"],
  top: 20,
});

// List all activity types at once
const allActivities = await client.me.list("caseactivities", {
  subtype: "all",
  orderBy: "createdon:desc",
});

// Each record includes a _link to its canonical URL
for (const activity of allActivities.data) {
  console.log(activity._link); // "/api/v2/default/me/caseactivities/phonecall/a1b2c3d4-..."
}

Paging through results

The API uses cursor-based paging powered by Dataverse's $skiptoken. Each list response includes a page object:

{
  data: [...],           // records for this page
  page: {
    top: 25,             // page size
    skip: 0,             // offset of this page
    next: "/api/v2/..." // full URL for next page (null on last page)
  }
}

Important: The page.next URL contains both skip and skiptoken (Dataverse's opaque paging cookie). Always use page.next to advance — don't manually increment skip, as the skiptoken is required for correct cursor positioning.

Fetch next page

Use fetchPage() to follow a page.next URL:

let page = await client.me.list<Case>("case", { top: 25 });
console.log(`Page 1: ${page.data.length} records`);

while (page.page.next) {
  page = await client.me.fetchPage<Case>(page.page.next);
  console.log(`Next page: ${page.data.length} records (offset ${page.page.skip})`);
}

Iterate all pages

Use eachPage() for a clean async iterator over every page:

for await (const page of client.me.eachPage<Case>("case", { top: 50 })) {
  for (const record of page.data) {
    console.log(record.ticketnumber, record.title);
  }
}

Collect all records

const allCases: Case[] = [];
for await (const page of client.all.eachPage<Case>("case", { top: 100 })) {
  allCases.push(...page.data);
}
console.log(`Total: ${allCases.length} cases`);

Get a single record

const result = await client.me.get("case", caseId, {
  select: ["title", "description", "statuscode", "customerid"],
});

const c = result.data;
console.log(c.title);             // "Network outage in building 3"
console.log(c._customerid_value); // GUID of the linked customer
console.log(c.statuscode_label);  // "In Progress"

Create a record (me scope only)

const result = await client.me.create("casenotes", {
  subject: "Follow-up call",
  notetext: "Spoke with customer — issue resolved.",
  objectid_incident: incidentId, // link to parent case
});

console.log(result.data.annotationid); // new note GUID

The API automatically binds contact and account relationship fields when creating records. For tables with auto-binding configured (like incident), you only need to provide content fields:

// Create a case — contact and account are auto-bound from your identity
const newCase = await client.me.create("case", {
  title: "VPN not connecting",
  description: "Getting timeout errors when connecting to corporate VPN.",
});

console.log(newCase.data.incidentid);   // new case GUID
console.log(newCase.data.ticketnumber); // auto-generated case number

You do not need to set primarycontactid or customerid_account — the API resolves these from your authenticated identity and binds them automatically.

Update a record

Available on all scopes (me, team, all):

const result = await client.me.update("case", caseId, {
  description: "Updated with latest findings",
});

console.log(result.data.modifiedon); // "2026-02-13T10:30:00Z"

Lookup (summary search)

Returns only summary fields (ID + display name) — no addresses, phone numbers, or sensitive details. Use for listing names, finding record IDs, or populating references safely.

const result = await client.me.lookup("contact", {
  search: "john",
  top: 10,
});

// Returns names and IDs only — no full contact details
for (const contact of result.data) {
  console.log(contact.fullname);
}

With types: any operation accepts a generic — client.me.lookup<Contact>("contact", ...), client.me.get<Case>("case", ...), etc.

Who am I

const me = await client.me.whoami();
console.log(me.fullname);   // "Jane Developer"
console.log(me.email);      // "[email protected]"
console.log(me.contactid);  // Dataverse contact GUID
console.log(me.accountid);  // Dataverse account GUID (if linked)

Choices (option sets)

Choice fields (picklists) have their values managed in Dataverse. Fetch them for building dropdowns:

// All choice fields for a table
const allChoices = await client.choices("case");
// allChoices.fields.statuscode → [{ value: 1, label: "In Progress" }, ...]
// allChoices.fields.prioritycode → [{ value: 1, label: "High" }, ...]

// Single field
const statusChoices = await client.choices("case", "statuscode");
// statusChoices.choices → [{ value: 1, label: "In Progress" }, ...]

Schema

// All table schemas
const schemas = await client.schema();

// Single table
const caseSchema = await client.schema("case");
console.log(caseSchema.fields);       // field definitions with types + descriptions
console.log(caseSchema.defaultFields); // fields returned when no `select` is specified

Query options

Used by list():

| Option | Type | Description | |--------|------|-------------| | select | string[] | Fields to return | | top | number | Page size (1-100) | | skip | number | Initial offset (rarely needed). For paging through results, always use fetchPage(page.next) or eachPage() instead — Dataverse requires the $skiptoken cursor from page.next, not just a skip offset. | | orderBy | string | Sort field and direction, e.g. "createdon:desc" | | filter | string \| string[] | OData-style filter expressions | | filterLogic | "and" \| "or" | How to combine multiple filters (default: "and") | | expand | string | Expand related lookups | | subtype | string | Activity subtype filter (e.g. "phonecall", "email", "task", "all") — only for activity tables | | created | string | Quick filter on creation date — e.g. "today", "7d", "thismonth", "2026-01-01..2026-02-01" | | modified | string | Quick filter on last-modified date (same formats as created) |

lookup() accepts the same options plus:

| Option | Type | Description | |--------|------|-------------| | search | string | Search term to filter by name/title |

get() accepts only select and expand.

Quick date filter values

| Format | Examples | Description | |--------|----------|-------------| | Named period | today, yesterday, thisweek, lastweek, thismonth, lastmonth, thisyear | Human-friendly period presets | | Relative | 1h, 24h, 7d, 30d, 90d | Sliding window from now | | Date range | 2026-01-01..2026-02-01, 2026-01-01.., ..2026-02-01 | Explicit date range (inclusive start, exclusive end) |

// Cases created in the last 7 days
const recent = await client.me.list<Case>("case", { created: "7d" });

// Cases modified today
const updated = await client.me.list<Case>("case", { modified: "today" });

// Combine with regular filters
const urgentRecent = await client.me.list<Case>("case", {
  created: "thismonth",
  filter: "prioritycode eq 1",
});

The SDK resolves these client-side — dates are converted into standard filter conditions before the request is sent. This means quick dates work with any server version, even older deployments that don't support the created/modified parameters natively.

Standalone utility

The resolution logic is also exported for direct use:

import { resolveQuickDate, quickDateToFilters } from "@truenorth-it/dataverse-client";

resolveQuickDate("7d");
// → { ge: "2026-02-08T12:00:00.000Z" }

resolveQuickDate("today");
// → { ge: "2026-02-15T00:00:00.000Z", lt: "2026-02-16T00:00:00.000Z" }

quickDateToFilters("createdon", "thisweek");
// → ["createdon ge 2026-02-09T00:00:00.000Z"]

Error handling

import { ApiError } from "@truenorth-it/dataverse-client";

try {
  await client.me.list("case");
} catch (err) {
  if (err instanceof ApiError) {
    console.error(err.status);  // HTTP status code
    console.error(err.body);    // Error response body
  }
}

| Status | Meaning | |--------|---------| | 401 | Missing or invalid token | | 403 | Insufficient permissions | | 404 | Unknown table or contact not found |

Real-time notifications

The SDK includes a React hook that connects to Azure SignalR and automatically invalidates TanStack Query caches when data changes on the server. When another user (or an MCP client) creates or updates a record, your portal's UI refreshes instantly — no polling required.

Prerequisites

Install the SignalR client as a peer dependency:

npm install @microsoft/signalr

The API must have SIGNALR_CONNECTION_STRING configured. When it's not set, negotiate() returns a 404 and the hook gracefully does nothing.

Negotiate

The client exposes a negotiate() method that exchanges your Auth0 token for a SignalR connection token:

const { url, accessToken } = await client.negotiate();

You don't usually call this directly — the useRealtime hook handles it.

useRealtime hook

import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { createClient, useRealtime } from "@truenorth-it/dataverse-client";

function App() {
  const client = useApiClient();
  const queryClient = useQueryClient();

  const negotiate = useCallback(() => client.negotiate(), [client]);

  const { connected, error } = useRealtime({
    negotiate,
    queryClient,
  });

  // connected: true when the SignalR connection is active
  // error: last error message, or null
}

That's all you need. When a dataChanged event arrives (e.g. for table "case"), the hook automatically calls queryClient.invalidateQueries() for the matching query keys. TanStack Query refetches in the background and your UI updates.

Options

interface RealtimeOptions {
  negotiate: () => Promise<{ url: string; accessToken: string }>;
  queryClient: QueryClient;
  tableQueryKeys?: Record<string, readonly (readonly unknown[])[]>;
  onEvent?: (event: DataChangeEvent) => void;
  enabled?: boolean; // default: true
}

| Option | Description | |--------|-------------| | negotiate | Function that returns a SignalR token (from client.negotiate()) | | queryClient | TanStack React Query client instance | | tableQueryKeys | Custom mapping of table names to query key prefixes (see below) | | onEvent | Optional callback for every data change event | | enabled | Set to false to disable the connection |

Default table-to-query-key mapping

The hook ships with sensible defaults:

{
  case:            [["cases"], ["aggStats"]],
  casenotes:       [["caseNotes"]],
  caseactivities:  [["caseActivities"]],
  caseemails:      [["caseActivities"]],
  casephonecalls:  [["caseActivities"]],
  casetasks:       [["caseActivities"]],
  caseappointments:[["caseActivities"]],
}

Override or extend for your own tables:

useRealtime({
  negotiate,
  queryClient,
  tableQueryKeys: {
    // Keep defaults
    case: [["cases"], ["aggStats"]],
    casenotes: [["caseNotes"]],
    // Add your custom tables
    invoice: [["invoices"]],
    project: [["projects"], ["projectStats"]],
  },
});

DataChangeEvent

Events received from the server:

interface DataChangeEvent {
  table: string;                              // e.g. "case", "casenotes"
  action: "created" | "updated" | "deleted";
  recordId: string;                           // GUID of the affected record
  timestamp: string;                          // ISO 8601
  actor?: string;                             // email of the user who made the change
}

Response field patterns

These patterns apply to all API responses, whether or not you use generated types.

Choice and lookup field siblings

Choice fields (picklists) include a _label sibling with the display text:

record.statuscode        // 1 (numeric value)
record.statuscode_label  // "In Progress" (display text from Dataverse)

Lookup fields include a _value sibling with the referenced record's GUID:

record._customerid_value      // "a1b2c3d4-..." (GUID of linked customer)
record._primarycontactid_value // "e5f6g7h8-..." (GUID of linked contact)

TableName union (generated types only)

If you use codegen, the generated file includes a TableName union of all valid table names and aliases:

import type { TableName } from "./dataverse-tables.generated";

// Type-safe table parameter — accepts "case", "case", "contact", etc.
function fetchRecords(table: TableName) {
  return client.me.list(table);
}

Exported types

All types are importable from the main package:

import type {
  // Client & scope interfaces
  DataverseClient, ScopeClient, MeScopeClient, ClientConfig,
  // Query types
  QueryOptions, LookupOptions,
  // Response types
  PaginatedResponse, SingleResponse, WhoamiResponse,
  // Schema & choices
  TableSchema, SchemaField, ExpandSchema, Choice,
  FieldChoicesResponse, TableChoicesResponse,
  // Codegen types (for programmatic usage)
  SchemaResponse, SchemaTableInput,
  // Errors
  ApiErrorBody,
  // Real-time notifications
  NegotiateResponse, DataChangeEvent, RealtimeOptions, RealtimeState,
} from "@truenorth-it/dataverse-client";

// React hook (requires @microsoft/signalr peer dependency)
import { useRealtime } from "@truenorth-it/dataverse-client";

Table-specific types come from your generated file:

import type { Case, Contact, TableName } from "./dataverse-tables.generated";

Requirements

  • Node.js 18+
  • An Auth0 token provider (or any async function that returns a Bearer token)
  • Access to a deployed Dataverse Contact API instance

License

ISC