Data model how-to guides
Goal-oriented, runnable recipes for the Vectros data model. Every snippet is grounded in calls that run against the live platform. Examples use the Node SDK; data is synthetic throughout (fictional names and values only).
Setup
Install and construct a client once. Sub-clients are grouped by area
(client.schemas, client.records, client.documents, client.folders,
client.search).
import { VectrosClient } from '@vectros-ai/sdk';
const client = new VectrosClient({
token: process.env.VECTROS_API_KEY!, // sk_*, ssk_*, or st_* credential
environment: 'https://api.vectros.ai', // or your staging base URL
});
Version note. The API spec is currently at 0.27.0. Two calls in this guide require SDK 0.26+ and are marked inline: record/document/folder PATCH, and creating a record by
typeNamealone (thetypeNameXORschemaIdeither-or). The CLI and MCP server already bundle a 0.26 staging build, so they have these; the React toolkit and the reference web apps still pin a 0.23 staging build, so these calls are not reachable through those apps' own UI. Everything else works on a 0.23 client too.
Define a schema
Goal: declare a record type with validated fields, a searchable field, a filterable field, and a unique lookup field.
const schema = await client.schemas.createSchema({
typeName: 'patient_intake',
displayName: 'Patient Intake Record',
indexMode: 'HYBRID', // index instances for hybrid search
allowedSurfaces: ['record'], // REQUIRED, non-empty
fields: [
{ fieldId: 'name', fieldType: 'string', required: true, searchable: true },
{ fieldId: 'notes', fieldType: 'string', required: false, searchable: true },
{ fieldId: 'department', fieldType: 'string', required: false, filterable: true },
{ fieldId: 'email', fieldType: 'string', required: true, searchable: false },
],
lookupFields: [{ fieldName: 'email', unique: true }],
capabilities: { auditHistory: true },
});
const schemaId = schema.id!;
Expected result: a schema with a system-assigned id and schemaVersion: 1.
allowedSurfaces is required — omitting it is a 400.
A bare schema (just
typeName,displayName,allowedSurfaces) is valid; records written against it are stored without payload validation.
Link records with a reference field
Goal: declare a typed link from one record type to another.
A field with fieldType: 'reference' carries a typed link to another record (or to an
identity). It takes a few extra keys on the field definition:
await client.schemas.createSchema({
typeName: 'encounter',
displayName: 'Clinical Encounter',
indexMode: 'HYBRID',
allowedSurfaces: ['record'],
fields: [
{ fieldId: 'summary', fieldType: 'string', required: true, searchable: true },
{
fieldId: 'patient',
fieldType: 'reference',
targetTypeName: 'patient_intake', // required: the type pointed at
targetSurface: 'record', // required: record/document/user/org/client
targetField: 'email', // optional: a unique lookup on the target (default externalId)
cardinality: 'one', // optional: one (default) | many
},
],
});
Behavior: the link is structural — the schema declares and stores it, but the
platform does not yet enforce the target's existence or type at write time, so a reference
value is accepted without being resolved. targetTypeName and targetSurface are required
on a reference field; targetField must name a unique lookup on the target type.
Create a record
Goal: write a validated record and confirm it indexed.
const record = await client.records.createRecord({
typeName: 'patient_intake',
schemaId, // typeName + schemaId may both be given (must agree)
payload: {
name: 'Jane Doe',
notes: 'presents with hypertension and complains of chest pain',
department: 'cardiology',
email: 'jane.doe@example.com',
},
userId, // optional ownership (Vectros user UUID)
orgId, // optional ownership (Vectros org UUID)
});
// Freshly created records are queued for indexing.
// record.indexStatus === 'PENDING_INDEX'
SDK 0.26+ variant — create by typeName alone. The record type (typeName) is unique
within your tenant and context, so you may omit schemaId and let the server resolve the schema:
// (SDK 0.26+)
const record = await client.records.createRecord({
typeName: 'patient_intake',
payload: { name: 'Jane Doe', email: 'jane.doe@example.com', department: 'cardiology' },
});
Expected result: a RecordResponse with an id, version: 1, and
indexStatus: 'PENDING_INDEX'. Poll the record by id until indexStatus is INDEXED
before relying on it surfacing in search.
Get a record
Goal: read a record back by id.
const loaded = await client.records.getRecord({ id: record.id! });
// loaded.payload, loaded.userId, loaded.version, loaded.indexStatus ...
A by-id GET always returns the full payload (even when it is externalized for size).
Update a record (PUT — full replace)
Goal: change fields with last-write-wins semantics. Remember: payload is replaced
in full, so resend the complete payload.
await client.records.updateRecord({
id: record.id!,
body: {
typeName: 'patient_intake',
schemaId,
payload: {
name: 'Jane Doe',
notes: 'updated note', // changed
department: 'cardiology',
email: 'jane.doe@example.com', // re-supplied — omitting it would drop it
},
userId,
orgId,
},
});
Expected result: the record's version increments; an audit version row capturing
the prior state is written asynchronously.
Make the update conditional (optimistic concurrency)
Pass the version you last read as expectedVersion:
await client.records.updateRecord({
id: record.id!,
body: {
typeName: 'patient_intake',
schemaId,
payload: { /* full payload */ },
expectedVersion: loaded.version, // reject with 409 if the record moved on
},
});
Expected result: 409 VERSION_CONFLICT if another writer changed the record since
you read it; the stored record is left untouched.
Patch a record (PATCH — true partial update, SDK 0.26+)
Goal: change a single payload field without resending the whole payload, using RFC 7386 JSON Merge Patch.
// (SDK 0.26+)
await client.records.patchRecord({
id: record.id!,
body: {
payload: {
notes: 'patched note', // overwrite this key only
department: null, // null DELETES this key from the payload
},
status: 'ARCHIVED', // change a top-level mutable field
},
});
Behavior: keys present in payload overwrite; nested objects recurse; a key set to
null inside payload is deleted; absent keys are preserved. A top-level field
(such as status or folderId) set to null is not a delete — it is rejected with
400. Immutable fields (typeName, schemaId, externalId, indexMode) are rejected if
present. expectedVersion may be included for conditional patches.
Look a record up by field
Goal: fetch a record directly by a declared lookup field — no scan.
const results = await client.records.lookupRecords({
type: 'patient_intake',
field: 'email',
value: 'jane.doe@example.com',
});
// FC-01 envelope: { data, nextCursor }
const found = results.data ?? []; // email is unique → exactly one row
// results.nextCursor === null
Expected result: for a unique lookup field, data has at most one element. For a
non-unique field the result is an enumeration — page it with nextCursor (see below).
Sensitive fields: if the lookup field is marked
sensitive, the value may not travel in a URL. Use the body-based variant so the value stays out of access logs. The same{ data, nextCursor }envelope applies.
List and paginate (drain the cursor)
Goal: retrieve every record of a type, draining the FC-01 { data, nextCursor }
envelope. The pattern is identical for listRecords, listSchemas, listDocuments,
listFolders, and the lookup/versions endpoints.
const ids: string[] = [];
let cursor: string | null | undefined;
do {
const page = await client.records.listRecords(
cursor ? { type: 'patient_intake', startFrom: cursor, limit: 100 }
: { type: 'patient_intake', limit: 100 });
ids.push(...(page.data ?? []).map((r) => r.id!));
cursor = page.nextCursor;
} while (cursor);
Expected result: every page's data accumulated; the loop ends when nextCursor
is null. Feed nextCursor back as startFrom — never compute offsets.
You can narrow a list by ownership — listRecords({ type, userId }) or
{ type, orgId } route through the ownership lookups:
const mine = await client.records.listRecords({ type: 'patient_intake', userId, limit: 100 });
Read version history
Goal: inspect the audit trail for a record — the immutable change rows.
// Create, update, then read history.
const page = await client.records.getRecordVersions({ id: record.id! });
const versions = page.data ?? []; // FC-01 { data, nextCursor } envelope
for (const v of versions) {
// v.changeType: 'CREATE' | 'UPDATE' | 'DELETE'
// v.previousContent: JSON string of the state BEFORE this change (null on CREATE)
// v.changedBy, v.createdAt, v.changedFields
}
const update = versions.find((v) => v.changeType === 'UPDATE');
if (update?.previousContent) {
const before = JSON.parse(update.previousContent);
// before.payload.notes === 'original note' (the pre-update state)
}
Expected result: at least one CREATE row plus one UPDATE row after an update.
Version rows are written asynchronously (typically 1–3 seconds after the write returns),
so poll briefly if you read history immediately after a write. The current state lives on
the record itself; history captures the "before" side of each transition.
Confirm a delete left a tombstone
Goal: hard-delete a record and verify the tombstone.
await client.records.deleteRecord({ id: record.id! });
// Tombstone propagates shortly after delete.
const tomb = await client.records.getRecordTombstone({ id: record.id! });
Expected result: the record, its lookup rows, and its search-index entry are gone; the tombstone row remains for audit.
Ingest a text document (search-ready)
Goal: ingest inline text, wait for indexing, and retrieve it via search.
const doc = await client.documents.ingestDocument({
title: 'Hypertension Clinical Guidelines',
text: 'Hypertension, commonly known as high blood pressure, is defined as ' +
'systolic blood pressure consistently above 130 mmHg ... (full body)',
indexMode: 'HYBRID',
storeText: true, // keep the raw text retrievable
folderId, // optional
userId, // optional ownership
payload: { // free-form, filterable by key in search
category: 'clinical-guidelines',
specialty: 'cardiology',
},
});
// doc.status === 'PENDING_INDEX' → poll getDocument until 'INDEXED'
const results = await client.search.content({
query: 'blood pressure',
mode: 'HYBRID',
limit: 50,
});
const hit = (results.results ?? []).find((r) => r.documentId === doc.id);
Expected result: the document indexes and surfaces in hybrid search. search.content
is not enveloped — read results.results directly. (Hybrid search modes, scoring, and
filters are covered in the search documentation.)
Retrieve the stored text
const resp = await client.documents.getDocumentText({ id: doc.id! });
// resp.text contains the original body (because storeText was true)
Upload a file (presigned handshake)
Goal: upload a file in the three-step handshake — request URL, PUT bytes, poll.
import * as fs from 'fs';
// Step 1 — request a presigned upload URL.
const upload = await client.documents.uploadDocument({
fileName: 'clinical-note.pdf',
fileType: 'application/pdf',
indexMode: 'HYBRID',
userId,
payload: { category: 'clinical-note' },
});
// upload.uploadUrl, upload.expiresAt (ISO-8601)
// Step 2 — PUT the raw bytes to the URL. NO Authorization header on this request.
const body = fs.readFileSync('clinical-note.pdf');
const put = await fetch(upload.uploadUrl!, {
method: 'PUT',
body,
headers: { 'Content-Type': 'application/pdf' },
});
// put.status === 200
// Step 3 — poll until INDEXED (file extraction adds latency).
let loaded = await client.documents.getDocument({ id: upload.id! });
// repeat getDocument until loaded.status === 'INDEXED'
Expected result: the file's text is extracted, indexed, and searchable. Fetch the
file later via getDocumentDownloadUrl({ id }), which returns a presigned downloadUrl.
Patch a document (SDK 0.26+)
Goal: change document metadata without resending everything.
// (SDK 0.26+)
await client.documents.patchDocument({
id: doc.id!,
body: {
title: 'Hypertension Guidelines (2024 revision)',
payload: { specialty: 'cardiology', reviewed: true }, // merged into existing payload
},
});
To re-ingest the body, include text in the patch — it is re-indexed write-through, the
same as a PUT update.
Create a folder
Goal: create a folder and a subfolder.
const root = await client.folders.createFolder({
name: 'Patient Records 2024',
});
// root.parentFolderId is the context's protected root (not null);
// root.isProtected === false
const sub = await client.folders.createFolder({
name: 'Intake Forms',
parentFolderId: root.id!, // set at CREATE only — folders cannot be moved
});
// sub.parentFolderId === root.id
Expected result: folders nest under the given parent (or the context root if omitted). A folder's parent is fixed at creation; there is no move/reparent operation. Deleting a folder that still has children is rejected with a 400 — empty it first.
Where to go next
- explanation.md — the concepts behind these calls.
- reference.md — every method, parameter, validation rule, limit, and error code.
- ../operations-trust/compliance.md — version history retention and sensitive-data handling.