@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-clientQuick 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" }); // typedGenerate 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.ukThis 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 onresult.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.nextURL contains bothskipandskiptoken(Dataverse's opaque paging cookie). Always usepage.nextto advance — don't manually incrementskip, 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 GUIDThe 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 numberYou 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 specifiedQuery 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/signalrThe 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
