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.12.0

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.

Downloads

2,602

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(), // any function returning a Bearer token (e.g. MSAL for Entra External ID)
});

const result = await client.me.list("case", {
  select: ["title", "ticketnumber", "statuscode", "createdon"],
  top: 20,
  orderBy: { field: "modifiedon", direction: "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 --scope servicebuilder

generate runs a suite of generators and writes their combined output to one file (dataverse.generated.ts). Two are built in, and each only contributes what applies to the scope:

  • tables — for every table: a typed interface, const enums for choice fields, a <Table>Field union (for typed select/filter/orderBy), and <Table>CreateInput / <Table>UpdateInput (the writable subset).
  • service-forms — for a Service Builder scope, an interface per service's submitted form (lcc_requestdata), plus a ServiceRequestDataByType slug-to-type map and a ServiceType union. Services whose schema errors in Dataverse are skipped with a logged note; the rest still generate.

Both use public endpoints (no auth). Output is deterministic (sorted), so regenerating produces byte-identical files unless something actually changed and diffs stay clean.

CLI options

| Flag | Default | Description | |------|---------|-------------| | --url, -u | (required) | Base URL of your API deployment | | --output, -o | ./dataverse.generated.ts | Output file path | | --api-base | /api/v2 | API base path (before scope segment) | | --scope, -s | default | API scope segment | | --only <name> | (all) | Run just one generator (e.g. tables, service-forms) |

Add more generators

The suite is extensible. An npm package can implement the Generator contract and be registered so generate picks it up:

npx dataverse-client add @your-org/dataverse-zod-gen

This installs the package and appends it to dataverse.config.json:

{ "generators": ["@your-org/dataverse-zod-gen"] }

generate then dynamically imports each listed package (resolved from your node_modules) and runs it alongside the built-ins. A generator is just:

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

const gen: Generator = {
  name: "zod",
  applies: (ctx) => ctx.scope === "servicebuilder",   // optional gate
  run: async (ctx) => {
    // ctx gives you: scope, baseUrl, apiBase, a ready `client`, and `fetchJson(path)`
    return "/* generated TypeScript source */";
  },
};
export default gen;

Built-in generators always run; the config only lists the extra ones. Package names are validated before install (no shell injection), and a package that can't be loaded is skipped with a log rather than failing the run.

Using generated types

import { createClient } from "@truenorth-it/dataverse-client";
import type { Case, Contact } from "./dataverse.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.generated";
import { CaseStatuscode, CasePrioritycode } from "./dataverse.generated";

const cases = await client.me.list<Case>("case", {
  filter: { field: "statuscode", operator: "eq", value: 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

Typed query options (catch field typos at compile time)

Each table also gets a <Table>Field union. Pair it with QueryOptionsFor<Field> to have select, orderBy.field, and filter.field checked against the real field names — a typo becomes a compile error instead of a 400 at runtime. It's opt-in: plain QueryOptions still works untyped.

import type { Case, CaseField } from "./dataverse.generated";
import type { QueryOptionsFor } from "@truenorth-it/dataverse-client";

const opts: QueryOptionsFor<CaseField> = {
  select: ["title", "statuscode"],          // "titel" → compile error
  orderBy: { field: "createdon", direction: "desc" },
  filter: { field: "statuscode", operator: "eq", value: 1 },
  // parent.<field> cross-entity filters on child tables are still allowed
};
const page = await client.me.list<Case>("case", opts);

Typed create / update payloads

<Table>CreateInput and <Table>UpdateInput describe just the writable fields. CreateInput also omits lookups the API auto-binds from your identity (via the table's create defaults), so you only pass what you actually set:

import type { CaseCreateInput } from "./dataverse.generated";

const input: CaseCreateInput = { title: "VPN down", description: "Site B offline" };
// customerid / primarycontactid are auto-bound — not part of CaseCreateInput
const created = await client.me.create<Case>("case", input);

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 + MSAL (Entra External ID)

import { useMemo } from "react";
import { useMsal } from "@azure/msal-react";
import { createClient } from "@truenorth-it/dataverse-client";

function useApiClient() {
  const { instance, accounts } = useMsal();

  return useMemo(
    () =>
      createClient({
        baseUrl: import.meta.env.VITE_API_BASE_URL,
        scope: "default",
        getToken: async () => {
          const { accessToken } = await instance.acquireTokenSilent({
            // Application ID URI of your API's Entra app registration
            scopes: [`${import.meta.env.VITE_API_SCOPE}/access_as_user`],
            account: accounts[0],
          });
          return accessToken;
        },
      }),
    [instance, accounts],
  );
}

The SDK is identity-provider-agnostic — getToken just needs to return a Bearer string. Swap MSAL for any other OIDC client if your deployment uses a different provider.

Client configuration

createClient({
  baseUrl: string;                     // API deployment URL
  getToken?: () => Promise<string>;    // Returns a Bearer token (optional for public-only access)
  apiBase?: string;                    // Defaults to "/api/v2"
  scope?: string;                      // Defaults to "default" — inserted after apiBase in URLs
  companyId?: string;                  // Act as a specific company (sends X-Company-Id). See "Multiple companies".
  contactId?: string;                  // Older alias (sends X-Contact-Id). Equivalent in the classic model.
  onResponse?: (info) => void;         // Observability hook — see below
});

Observability: onResponse

Called after every HTTP response the client receives (success and error alike) with { url, method, status, cache, durationMs, receivedAt }. cache is the server response-cache result ("HIT" / "MISS" from the X-Cache header, null when the cache is off). Hook errors are swallowed — they can never break a request. Useful for a debug overlay or telemetry:

const lastResponse = new Map<string, ResponseInfo>();
const client = createClient({
  baseUrl,
  getToken,
  onResponse: (info) => lastResponse.set(new URL(info.url).pathname, info),
});
// e.g. render "cache HIT · 42ms · 14:02:11" on a dashboard tile

Access scopes

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

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

Multiple companies

A person can act as more than one company. Most users have exactly one. When they have more, client.me.companies() lists them and client.withCompany(companyId) returns a client that acts as the chosen one — it sends the X-Company-Id header on every request; the server verifies the company belongs to you. With no selection, the API uses the default company.

Each company carries a stable companyId — the value to pass to withCompany(). (Depending on how the backing scope is configured, this is either the Dataverse contact id or the account id; you don't need to care which — just pass companyId back.) The older client.withContact(contactid) still works.

const { companies, hasMultiple } = await client.me.companies();
if (hasMultiple) {
  const globex = companies.find((c) => c.companyName === "Globex Inc")!;
  const globexClient = client.withCompany(globex.companyId);
  const cases = await globexClient.me.list("case"); // scoped to Globex
}

// whoami also reports the companies and a hasMultipleCompanies flag:
const me = await client.me.whoami();
console.log(me.dataverseContact?.companyName, me.hasMultipleCompanies);

Operations

List records

const result = await client.me.list("case", {
  select: ["title", "ticketnumber", "statuscode", "prioritycode", "createdon"],
  top: 50,
  orderBy: { field: "createdon", direction: "desc" },
  filter: { field: "statuscode", operator: "eq", value: 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: { field: "createdon", direction: "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.

Change tracking (cheap refetches)

Stay fresh without re-fetching whole lists — receive a delta of what changed (including edits made directly in Dataverse). The delta token lives on the client; the server holds no state.

changes() is the whole API. Call it with no token to seed (full current set + a token), then send the token back to get only what changed since:

// Seed once — drain: true follows a chunked large seed to completion for you
const seed = await client.me.changes("case", { drain: true });
// seed.changed = your current records, seed.deltaToken = save this

// Later — one small call returns only the delta
const delta = await client.me.changes("case", { deltaToken: seed.deltaToken, drain: true });
// delta.changed = created/updated records, delta.removed = deleted ids

Apply a delta to rows you already hold with the bundled pure helper:

import { mergeChanges } from "@truenorth-it/dataverse-client";
rows = mergeChanges(rows, delta, "incidentid"); // upserts changed, drops removed

Without drain, a large pull is delivered in chunks: keep calling with the returned deltaToken while hasMore is true. Requires the table to have change tracking enabled server-side. me/team are scoped to your records; multi-step tables serve all only. Available on client.me, client.team, client.all.

Recipe: delta-powered TanStack Query

Don't poll, and don't fight TanStack — let it decide when to fetch (mount, window focus, reconnect, invalidateQueries after a mutation) and use the delta to make every one of those refetches cheap. The query's data holds the rows and the token together; each refetch sends the token back, merges the delta into the previous rows, and stores the new token. Alt-tab back to the browser: the cached list renders instantly, and TanStack's normal focus refetch is a tiny delta call in the background — picking up external Dataverse edits (Power Automate, plugins, the DV UI) too.

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { mergeChanges, ApiError } from "@truenorth-it/dataverse-client";

interface DeltaList<T> { rows: T[]; deltaToken: string }

function useDeltaList<T extends Record<string, unknown>>(
  table: string, queryKey: readonly unknown[], idKey: string,
) {
  const qc = useQueryClient();
  return useQuery({
    queryKey,
    queryFn: async (): Promise<DeltaList<T>> => {
      const prev = qc.getQueryData<DeltaList<T>>(queryKey);
      try {
        const delta = await client.me.changes<T>(table, { deltaToken: prev?.deltaToken, drain: true });
        return { rows: mergeChanges(prev?.rows ?? [], delta, idKey), deltaToken: delta.deltaToken };
      } catch (err) {
        // Delta tokens expire (Dataverse change-tracking retention) — reseed from scratch.
        if (prev && err instanceof ApiError && err.status >= 400 && err.status < 500) {
          const seed = await client.me.changes<T>(table, { drain: true });
          return { rows: mergeChanges([], seed, idKey), deltaToken: seed.deltaToken };
        }
        throw err;
      }
    },
    select: (d) => d.rows,
  });
}

// Usage — refetches are deltas, so let TanStack refetch as eagerly as it likes:
const { data: cases } = useDeltaList<Case>("case", ["cases"], "incidentid");

That's the entire integration — no effects, no subscriptions, no cleanup. Because it's a plain useQuery, everything else composes as usual: errors surface on query.error, multiple components sharing the queryKey are deduped to one fetch, and mutations just call queryClient.invalidateQueries({ queryKey: ["cases"] }).

Notes:

  • Live while viewing? Add refetchInterval: 30_000 to the useQuery — still TanStack's decision, just configured. Skip it if focus/mutation freshness is enough.
  • Ordering: merged rows are not in server order — sort in select if the UI needs one (e.g. select: (d) => [...d.rows].sort(byCreatedOnDesc)).
  • Filtered views (["cases", { status: "open" }]): a delta row may not match the filter — keep one canonical per-table key like ["cases"] and derive filtered views from it in select or memos.
  • Persistence: with persistQueryClient, the token is persisted with the rows, so a returning session resumes with a delta instead of a full reload.
  • Already using SignalR? Don't double up — SignalR gives instant in-app updates; use the delta queryFn where external Dataverse edits need to show up on TanStack's refetch cadence.
  • This hook belongs in your app — the SDK stays framework-agnostic and just exposes changes() + mergeChanges().

Public (unauthenticated) access

Tables with publicRead: true are available without authentication via client.public. Only list and get are supported — no writes, lookups, or aggregates.

// No getToken needed for public-only usage
const client = createClient({
  baseUrl: "https://api.dataverse-contact.tnapps.co.uk",
});

const result = await client.public.list("case", {
  select: ["title", "ticketnumber", "statuscode"],
  top: 10,
});

const record = await client.public.get("case", caseId);

Paging works the same as authenticated scopes:

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

client.public never sends an Authorization header, even if getToken is configured on the client. The other scopes (me, team, all) still require authentication.

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)

Register (self-service contact)

For a signed-in user who has no Dataverse contact yet, register() provisions their own. The email is taken from the verified token (never the arguments) — only name fields are accepted — and the call is idempotent. Requires the scope to have self-registration enabled (otherwise the API returns 403).

const { created, dataverseContact } = await client.me.register({
  firstname: "Jane",
  lastname: "Developer",
});
// created === true on first call; false (existing contact returned) thereafter

To let a joiner also pick which companies they work with, list the ones on their email domain and pass the chosen accountIds. Each becomes one contact (the parent-account model uses one contact per company), and every id is re-verified server-side to be on the caller's domain:

const { companies } = await client.me.claimableCompanies();
// companies → [{ accountId, name, websiteurl?, city? }, ...]

const joined = await client.me.register({
  firstname: "Jane",
  lastname: "Developer",
  accountIds: companies.map((c) => c.accountId),
});
// joined.companies → every company the caller is now linked to

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

Custom APIs (actions & functions)

Invoke Dataverse custom APIs via the public scope client. Functions are read-only (GET), actions have side effects (POST). Both support entity-bound calls via recordId.

// Invoke a function with query parameters
const result = await client.public.invokeFunction<{ result: TimeInfo[] }>(
  "expand-calendar",
  { recordId: calendarId, params: { Start: startIso, End: endIso } },
);
console.log(result.data?.result); // TimeInfo[]

// Invoke an action with a JSON body
const result = await client.public.invokeAction("approve-request", {
  recordId: requestId,
  body: { comment: "Looks good" },
});
console.log(result.apiType); // "action"
console.log(result.data);    // response payload or null

Custom APIs are served under the public tier (/public/actions/...) and do not require authentication. Create a client without getToken if you only need public access:

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

const result = await client.public.invokeFunction("expand-calendar", {
  recordId: calendarId,
  params: { Start: start, End: end },
});

Schema-driven field model (toFieldModel)

Some tables store a whole submitted form as one JSON blob (e.g. Service Builder's lcc_requestdata). Rather than have every front-end parse that raw JSON, pair it with its JSON Schema and let the SDK produce a clean, labelled tree.

toFieldModel(schema, value) is a generic, pure function — hand it any JSON Schema and any matching JSON value. It walks the schema's properties alongside the value and returns a FieldNode[] you can render field-by-field.

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

// 1. Fetch the JSON Schema (a custom API returns it, e.g. lcc_GetServiceSchema)
const schema = await client.public.invokeAction("service-schema", {
  body: { ServiceType, Version },
});

// 2. Transform the raw record data against it (a JSON string is parsed for you)
const fields = toFieldModel(schema.data, row.lcc_requestdata);

Each FieldNode is:

interface FieldNode {
  key: string;              // property key, e.g. "email-address"
  label: string;            // schema title/description, else a prettified key
  type: "string" | "number" | "integer" | "boolean" | "date" | "date-time"
      | "choice" | "object" | "array" | "unknown";
  value?: unknown;          // scalar value (absent for object/array)
  displayValue?: string;    // ready-to-show string (booleans as "Yes"/"No", etc.)
  required?: boolean;       // marked required by the parent schema
  options?: string[];       // for choice: the allowed labels (from `enum`)
  children?: FieldNode[];   // for object: nested field nodes
  items?: FieldNode[][];    // for array: one FieldNode[] per element
}

How shapes map:

  • scalar{ type, value, displayValue } (label from the schema description).
  • nested object (contact, location) → type: "object" with children.
  • array (location-list, metadata) → type: "array" with items — one node-set per element.
  • enumtype: "choice"; the value is already the label, and options lists the choices.
  • data key not in the schema → kept and rendered (object/array expanded, scalar as-is) rather than dropped.

Rendering is left to you — walk the tree and emit labels + values, recursing into children and items. Nodes come back in the schema's declared property order, then any extra data keys.

Generate a TypeScript type from a service form (generate-service)

toFieldModel is the runtime side (shape any value for generic rendering). Its compile-time twin generates a typed interface for a specific service, so a bespoke single-service UI gets autocomplete + type-checking on JSON.parse(lcc_requestdata).

# one service → stdout (redirect where you want it)
npx dataverse-client generate-service --url https://api.dataverse-contact.tnapps.co.uk \
    --scope servicebuilder --service report-a-flickering-street-light > src/services.generated.ts

# every service in the scope → one file with all interfaces + a lookup map
npx dataverse-client generate-service --url https://api.dataverse-contact.tnapps.co.uk \
    --scope servicebuilder --all -o src/services.generated.ts

The CLI fetches the form's JSON Schema from the scope's service-schema custom API (lcc_GetServiceSchema, public — no auth), then emits:

export interface ReportAFlickeringStreetLight {
  /** Your contact details */
  "primary-contact"?: {
    /** Email address */
    "email-address": string;              // required → non-optional
    "preferred-contact-method"?: "Email" | "Phone" | "Post";  // enum → union
  };
  /** What is wrong? */
  "fault-type": "Flickering" | "Always on" | "Not working" | "Damaged";
  /** Photos */
  photos?: Array<string>;
}

With --all you also get a slug→type lookup and a ServiceType union, so a generic list can narrow by service type:

export interface ServiceRequestDataByType {
  "report-a-flickering-street-light": ReportAFlickeringStreetLight;
  "missed-bin": MissedBin;
}
export type ServiceType = keyof ServiceRequestDataByType;

const data = JSON.parse(row.lcc_requestdata) as ServiceRequestDataByType[typeof row.lcc_servicetype];

Progress logs go to stderr, the generated TypeScript to stdout — so > file.ts captures only the types. Programmatic equivalent (no fetch): schemaToInterface(schema, name) and generateServiceTypes(services).

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 | OrderBy | Sort field and direction — { field: "createdon", direction: "desc" }. Direction defaults to "asc". | | filter | FilterCondition \| FilterCondition[] | Typed filter conditions — { field, operator, value }. Operators: eq, ne, gt, ge, lt, le, contains, startswith, endswith. | | 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: { field: "prioritycode", operator: "eq", value: 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 Bearer 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.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, PublicScopeClient, ClientConfig,
  // Query types
  QueryOptions, LookupOptions, OrderBy, FilterOperator, FilterCondition,
  // Response types
  PaginatedResponse, SingleResponse, ActionResponse, WhoamiResponse,
  // Schema & choices
  TableSchema, SchemaField, ExpandSchema, Choice,
  FieldChoicesResponse, TableChoicesResponse,
  // Codegen types (for programmatic usage)
  SchemaResponse, SchemaTableInput,
  // Field model (toFieldModel)
  JsonSchema, FieldNode, FieldType,
  // Service-schema type generator (schemaToInterface / generateServiceTypes)
  ServiceSchemaInput,
  // Errors
  ApiErrorBody,
  // Real-time notifications
  NegotiateResponse, DataChangeEvent, RealtimeOptions, RealtimeState,
} from "@truenorth-it/dataverse-client";

// Pure transformers (tree-shakeable)
import {
  toFieldModel, mergeChanges, generateTableTypes,
  schemaToInterface, generateServiceTypes,
} 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.generated";

Requirements

  • Node.js 18+
  • An async function that returns a Bearer token — typically MSAL for Entra External ID, but any OIDC client works (optional if using only client.public)
  • Access to a deployed Dataverse Contact API instance

License

ISC