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.38.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) — relevant only if your own integration pins an SDK build older than 0.26. Every first-party client (CLI, MCP server, React toolkit, reference web apps) is well past that line today.


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({
  body: {
    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.

Re-running this call is safe for you, not for a second owner. createSchema is idempotent by typeName within your own ownership scope — re-issuing it with your own credential returns your existing schema instead of failing. A different owner (a different userId/scopes) defining a schema under a typeName that already exists must set basedOn to the existing schema's id, making theirs a customization of it — an omitted basedOn on a name someone else already claimed is rejected with a 400. See the data model reference for the full basedOn contract.


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({
  body: {
    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, or an entity-backed namespace (org/client/…)
        targetField: 'email',               // optional: a unique lookup on the target (default externalId)
        cardinality: 'one',                 // optional: one (default) | many
      },
    ],
  },
});

Behavior: by default the platform resolves the reference at write time — the target record must already exist (matched on targetField), or the write is rejected. So create the target before the record that points at it. A schema can opt out with capabilities.validateReferences: false (an order-independent bulk load that accepts unresolved references). 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({
  body: {
    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)
    scopes: [`org:${orgId}`],            // optional ownership (namespaced entity edges, <= 2)
  },
});

// Freshly created records are queued for indexing.
// record.indexStatus === 'PENDING_INDEX'

SDK 0.26+ variant — create by typeName alone. You may omit schemaId and let the server resolve the schema — to your own basedOn variant of typeName when you have one, otherwise the shared base:

// (SDK 0.26+)
const record = await client.records.createRecord({
  body: {
    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. Treat FAILED as a terminal outcome too — stop polling and inspect indexFailure.code: TEXT_INDEX_FAILED, EMBEDDING_FAILED, and VECTOR_LIMIT_EXCEEDED mean the record is still findable by the surviving strategy, while INDEXING_FAILED / SOURCE_UNAVAILABLE mean it is not searchable at all. Branch on the code, not the message text (see the code table in the operations-trust reference).


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,
    scopes: [`org:${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',
});

// List 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.

Composite lookups. A schema can declare a lookup across two or three fields together (lookupFields: [{ fieldNames: ['status', 'area'] }] instead of a single fieldName). Query it with a comma-joined field and a matching values array, in the same order as fieldNames:

const grouped = await client.records.lookupRecords({
  type: 'support_ticket',
  field: 'status,area',
  values: ['open', 'east'],   // a single-element values means the same as value
});

Field order is fixed when the schema is authored: you can query the first field alone, the first two, and so on, but never a later field by itself — that needs its own lookup. Supply fewer values than the schema declares fields and the response groups by the fields you left out (sortBy then orders within each group, not across the whole result). A composite lookup can't be unique or rangeEnabled, and only binds to record schemas.

Narrow an exact lookup to a time window. An exact-value lookup accepts optional sortFrom/sortTo bounds on the lookup field's own sort key, inclusive, in that field's units (epoch milliseconds for a timestamp field such as createdAt):

const recent = await client.records.lookupRecords({
  type: 'patient_intake',
  field: 'department',
  value: 'cardiology',
  sortFrom: Date.now() - 30 * 24 * 60 * 60 * 1000,   // last 30 days
});

On a composite lookup, a sortFrom/sortTo window needs the full tuple of values — it isn't available on a grouped/partial query. Records with no value on the sorted field sort ahead of ones that have one, and are excluded from a bounded window.


List and paginate (drain the cursor)

Goal: retrieve every record of a type, draining the { data, nextCursor } list 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 — that is the only signal that you're done. Feed the verbatim nextCursor back as startFrom; never compute your own offsets or construct a startFrom value (a caller-constructed one is rejected with 400). A page can come back short, or even empty, while more results remain, so don't treat page size or fullness as a completion signal — only nextCursor === null means "no more data."

You can narrow a list by ownership — listRecords({ type, userId }) or { type, scope: 'org:<id>' } route through the ownership lookups (one scope filter per call):

const mine = await client.records.listRecords({ type: 'patient_intake', userId, limit: 100 });
const orgsRecords = await client.records.listRecords({ type: 'patient_intake', scope: `org:${orgId}`, 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 ?? [];      // { data, nextCursor } list 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({
  body: {
    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',
    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'
// (or 'FAILED' — a terminal outcome; inspect indexFailure.code)

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 (text-ingested documents always retain it)

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'
// (or 'FAILED' — stop and inspect loaded.indexFailure.code; a failed
//  extraction never reaches 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({
  body: {
    name: 'Patient Records 2024',
  },
});
// root.parentFolderId is the context's protected root (not null);
// root.isProtected === false

const sub = await client.folders.createFolder({
  body: {
    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