@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-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(), // 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" }); // 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.uk --scope servicebuildergenerate 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>Fieldunion (for typedselect/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 aServiceRequestDataByTypeslug-to-type map and aServiceTypeunion. 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-genThis 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 tileAccess 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 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: { 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.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.
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 idsApply 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 removedWithout 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_000to theuseQuery— still TanStack's decision, just configured. Skip it if focus/mutation freshness is enough. - Ordering: merged rows are not in server order — sort in
selectif 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 inselector 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.publicnever sends anAuthorizationheader, even ifgetTokenis 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) thereafterTo 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 toChoices (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 specifiedCustom 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 nullCustom 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 schemadescription). - nested object (contact, location) →
type: "object"withchildren. - array (location-list, metadata) →
type: "array"withitems— one node-set per element. enum→type: "choice"; the value is already the label, andoptionslists 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.tsThe 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/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 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
