# 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`).

```ts
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.29.9**. 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.

```ts
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:

```ts
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:** 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.

```ts
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:

```ts
// (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.

```ts
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.

```ts
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`:

```ts
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.

```ts
// (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.

```ts
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.

```ts
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:

```ts
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.

```ts
// 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.

```ts
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.

```ts
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

```ts
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.

```ts
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.

```ts
// (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.

```ts
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](explanation.md) — the concepts behind these calls.
- [reference.md](reference.md) — every method, parameter, validation rule, limit, and
  error code.
- [../operations-trust/compliance.md](../operations-trust/compliance.md) — version
  history retention and sensitive-data handling.
