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 typeName alone (the typeName XOR schemaId either-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.


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