Advanced
Work with Composite Primary Keys
Some collections use a composite primary key -- two or more fields that together identify a unique item. Junction collections that link items through many-to-many relations are a common example. Pass the key as an object containing all key fields.
A composite key cannot go in the URL path. The SDK converts it into a filter with _and conditions:
const postTag = await client.PostTags.readOne({
key: { post_id: 'p1', tag_id: 't1' },
fields: ['post_id', 'tag_id'],
});
// Inferred type:
// {
// post_id: string | null;
// tag_id: string | null;
// }
# Composite key uses a filter-based lookup
curl -g "https://example.monospace.io/api/blog/items/PostTags?fields=post_id,tag_id&filter[post_id][_eq]=p1&filter[tag_id][_eq]=t1&limit=1" \
-H "Authorization: Bearer YOUR_API_KEY"
const params = new URLSearchParams({
'fields': 'post_id,tag_id',
'filter[post_id][_eq]': 'p1',
'filter[tag_id][_eq]': 't1',
'limit': '1',
});
const response = await fetch(
`https://example.monospace.io/api/blog/items/PostTags?${params}`,
{
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
const { data } = await response.json();
The key object must include every field in the composite primary key. TypeScript reports an error if you omit one.
Update and delete work the same way:
// Update a junction item
await client.PostTags.updateOne({
key: { post_id: 'p1', tag_id: 't1' },
data: { sort_order: 2 },
fields: ['post_id', 'tag_id', 'sort_order'],
});
// Delete a junction item
await client.PostTags.deleteOne({
key: { post_id: 'p1', tag_id: 't1' },
});
# Update a junction item
curl -X PATCH -g "https://example.monospace.io/api/blog/items/PostTags?filter[post_id][_eq]=p1&filter[tag_id][_eq]=t1" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sort_order": 2}'
# Delete a junction item
curl -X DELETE -g "https://example.monospace.io/api/blog/items/PostTags?filter[post_id][_eq]=p1&filter[tag_id][_eq]=t1" \
-H "Authorization: Bearer YOUR_API_KEY"
// Update a junction item
const updateParams = new URLSearchParams({
'filter[post_id][_eq]': 'p1',
'filter[tag_id][_eq]': 't1',
});
await fetch(
`https://example.monospace.io/api/blog/items/PostTags?${updateParams}`,
{
method: 'PATCH',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({ sort_order: 2 }),
},
);
// Delete a junction item
const deleteParams = new URLSearchParams({
'filter[post_id][_eq]': 'p1',
'filter[tag_id][_eq]': 't1',
});
await fetch(
`https://example.monospace.io/api/blog/items/PostTags?${deleteParams}`,
{
method: 'DELETE',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
Use Collections Without a Primary Key
Collections without a primary key only support readMany and readFirst. They lack a unique identifier for targeting an item, so readOne, updateOne, and deleteOne are unavailable.
// readMany — returns all matching items
const logs = await client.AuditLogs.readMany({
fields: ['timestamp', 'action', 'user_id'],
filter: { action: { _eq: 'login' } },
sort: [{ timestamp: { direction: 'desc' } }],
limit: 50,
});
// readFirst — returns the first matching item or null
const latest = await client.AuditLogs.readFirst({
fields: ['timestamp', 'action', 'user_id'],
sort: [{ timestamp: { direction: 'desc' } }],
});
readOne, updateOne, and deleteOne for these collections, so calling them produces a compile-time error.Access Collections by Name at Runtime
Use $-prefixed methods when you determine collection names at runtime (e.g., from user input, configuration, or a loop over collection metadata). Pass the collection name as a string argument:
// Read from a collection whose name is a variable
const collectionName = 'Articles';
const items = await client.$readMany(collectionName, {
fields: ['id', 'title'],
filter: { status: { _eq: 'published' } },
});
// Single item by key
const item = await client.$readOne(collectionName, {
key: 1,
fields: ['id', 'title', 'content'],
});
// First matching item
const first = await client.$readFirst(collectionName, {
fields: ['id', 'title'],
sort: [{ created_at: { direction: 'desc' } }],
});
Write operations work the same way:
const collectionName = 'Articles';
// Create
const created = await client.$createOne(collectionName, {
data: { title: 'New Article', status: 'draft' },
fields: ['id', 'title'],
});
// Create many
const batch = await client.$createMany(collectionName, {
data: [
{ title: 'First', status: 'draft' },
{ title: 'Second', status: 'draft' },
],
fields: ['id', 'title'],
});
// Update one
const updated = await client.$updateOne(collectionName, {
key: 1,
data: { status: 'published' },
fields: ['id', 'status'],
});
// Update many — uses filter, not keys
const updatedMany = await client.$updateMany(collectionName, {
filter: { id: { _in: [1, 2, 3] } },
data: { status: 'archived' },
fields: ['id', 'status'],
});
// Delete one
await client.$deleteOne(collectionName, { key: 1 });
// Delete many — uses filter, not keys
await client.$deleteMany(collectionName, {
filter: { id: { _in: [1, 2, 3] } },
});
Typed and untyped SDK methods use the same REST endpoints, regardless of how you determine the collection name:
const collection = 'Articles';
const items = await client.$readMany(collection, {
fields: ['id', 'title'],
});
curl "https://example.monospace.io/api/blog/items/Articles?fields=id,title" \
-H "Authorization: Bearer YOUR_API_KEY"
const collection = 'Articles';
const response = await fetch(
`https://example.monospace.io/api/blog/items/${collection}?fields=id,title`,
{
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
const { data } = await response.json();
$-prefixed methods skip type checking on collection names and fields. Use them for admin panels, migration scripts, or any case where collection names come from user input.Provide a Custom Type for Untyped Operations
Pass a generic type parameter to any $-prefixed method to get typed results without code generation. The SDK applies your type to the return value.
interface Article {
id: string;
title: string;
status: 'draft' | 'published' | 'archived';
created_at: string;
}
// Return type is Article[]
const articles = await client.$readMany<Article>('Articles', {
fields: ['id', 'title', 'status', 'created_at'],
filter: { status: { _eq: 'published' } },
});
// Return type is Article
const article = await client.$readOne<Article>('Articles', {
key: 'abc-123',
fields: ['id', 'title', 'status', 'created_at'],
});
// Return type is Article | null
const first = await client.$readFirst<Article>('Articles', {
fields: ['id', 'title'],
filter: { status: { _eq: 'published' } },
});
Use custom types when prototyping before code generation or querying collections outside your generated schema (e.g., collections in another workspace).
Response Envelope (unwrapEnvelope) {#response-envelope}
The Monospace REST API wraps every response in a { data: T } envelope. The unwrapEnvelope option controls whether the SDK strips this wrapper before returning results.
Unwrapped (default)
With unwrapEnvelope: true (the default), the SDK extracts data and returns it directly.
const client = createClient({
url: 'https://example.monospace.io',
workspace: 'blog',
apiKey: 'YOUR_API_KEY',
});
// Returns Article[] directly
const articles = await client.Articles.readMany({
fields: ['*'],
});
console.log(articles[0].title); // "Getting Started"
curl "https://example.monospace.io/api/blog/items/Articles?fields=*" \
-H "Authorization: Bearer YOUR_API_KEY"
# Response:
# {
# "data": [
# { "id": 1, "title": "Getting Started" },
# { "id": 2, "title": "Data Modeling" }
# ]
# }
const response = await fetch(
`https://example.monospace.io/api/blog/items/Articles?fields=*`,
{
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
const json = await response.json();
// json.data — you always get the envelope over HTTP
Wrapped
Set unwrapEnvelope: false at the client level to keep the full { data: T } envelope on responses. deleteOne and deleteMany without fields or include resolve to undefined regardless of this option.
const client = createClient({
url: 'https://example.monospace.io',
workspace: 'blog',
apiKey: 'YOUR_API_KEY',
unwrapEnvelope: false,
});
// Returns { data: Article[] }
const response = await client.Articles.readMany({
fields: ['*'],
});
console.log(response.data); // Article[]
console.log(response.data[0].title); // "Getting Started"
The envelope allows future metadata, such as pagination details, alongside data without changing the response structure. Wrapped mode preserves the envelope for those additions.
Per-request override
Override the client default on any individual call by passing the option as a second argument.
const client = createClient({
url: 'https://example.monospace.io',
workspace: 'blog',
apiKey: 'YOUR_API_KEY',
// unwrapEnvelope defaults to true
});
// Default — returns Article[] directly
const articles = await client.Articles.readMany({
fields: ['id', 'title'],
});
// Override for this request — returns { data: Article[] }
const response = await client.Articles.readMany(
{ fields: ['id', 'title'], limit: 10 },
{ unwrapEnvelope: false },
);
console.log(response.data); // Article[]
This works on any method, including readOne and readFirst:
const response = await client.Articles.readOne(
{
key: 'abc-123',
fields: ['id', 'title', 'status'],
},
{ unwrapEnvelope: false },
);
// response.data — Article
Null Strictness (strictNull) {#null-strictness}
The strictNull option controls how the SDK types fields and relations that can return null despite being declared non-nullable in the schema.
Why fields can be null at runtime
Conditional field-level permissions grant read access to a field only when an item meets a policy's condition. Otherwise, Monospace returns null for that field.
For example, consider a policy that grants access to salary only for employees in the current user's department. In the same response, salary is a number for employees in that department and null for others.
Relations that span two data sources behave the same way. Nothing enforces that the reference resolves, so a to-one relation returns null even when its backing field is non-nullable. See Cross-Data-Source Referential Integrity.
Fields with no read permission behave differently: wildcard requests omit them, and explicit requests cause a permission error. strictNull accounts for access that varies per item.
See Field Permissions for details on how to configure these policies.
Strict (default)
With strictNull: true (the default), all fields become T | null, including non-nullable schema fields like id. Keep this default to account for fields that return null on specific items.
const client = createClient({
url: 'https://example.monospace.io',
workspace: 'blog',
apiKey: 'YOUR_API_KEY',
strictNull: true, // default
});
const article = await client.Articles.readOne({
key: 1,
fields: ['id', 'title', 'body'],
});
// Every field is T | null
article.id; // number | null
article.title; // string | null
article.body; // string | null (even if body is required in the schema)
Relaxed
With strictNull: false, fields follow schema nullability only. A required title field is typed as string, not string | null.
const client = createClient({
url: 'https://example.monospace.io',
workspace: 'blog',
apiKey: 'YOUR_API_KEY',
strictNull: false,
});
const article = await client.Articles.readOne({
key: 1,
fields: ['id', 'title', 'body'],
});
// Fields follow schema nullability
article.id; // number (primary key, never null)
article.title; // string (required in schema)
article.body; // string | null (nullable in schema)
Use strictNull: false only when your credentials grant full field-level read access and your relations have enforced referential integrity. Otherwise the relaxed types can claim a field or relation is non-nullable when it returns null at runtime.
strictNull: false, conditionally restricted fields still return null at runtime, but their types follow the schema. Check that your API key's role has unconditional access to every field you query.Effect on relations
strictNull also controls the outer nullability of relational fields.
// strictNull: true (default)
// All relations get an outer | null regardless of schema
// {
// category: { id: string | null; name: string | null } | null;
// articles: { data: { title: string | null }[] } | null;
// }
// strictNull: false
// To-one nullability follows the FK (non-nullable FK = no outer null)
// To-many envelope becomes non-nullable
// {
// category: { id: string; name: string | null };
// articles: { data: { title: string | null }[] };
// }
Per-request override
Override the client default on any individual call by passing strictNull as a second argument.
// Client uses strictNull: true (the default)
// Default: all fields T | null
const artists = await client.Artists.readMany({
fields: ['id', 'name'],
});
// type: { id: string | null; name: string | null }[]
// Per-query override: nullability follows schema
const artistsStrict = await client.Artists.readMany(
{ fields: ['id', 'name'] },
{ strictNull: false },
);
// type: { id: string; name: string | null }[]
// ^ non-nullable in schema
Combine Per-Request Options
Set unwrapEnvelope and strictNull together in the second argument to any CRUD method:
const response = await client.Artists.readMany(
{ fields: ['id', 'name'] },
{ unwrapEnvelope: false, strictNull: false },
);
// type: { data: { id: string; name: string | null }[] }
The second argument accepts QueryOptions:
| Option | Type | Description |
|---|---|---|
unwrapEnvelope | boolean | Override client-level envelope unwrapping for this query |
strictNull | boolean | Override client-level null strictness for this query |
Per-request options override client settings. When neither is set, both options default to true.
Handle Errors
The SDK throws a MonospaceError for every API error response. Use instanceof and status to handle each case:
import { MonospaceError } from '@monospace/sdk';
try {
await client.Articles.readOne({ key: 999_999, fields: ['title'] });
} catch (error) {
if (error instanceof MonospaceError && error.status === 404) {
console.log(`Not found: ${error.source?.message}`);
// error.message — 'Query failed'
// error.source?.message — 'No record found'
}
}
The error hierarchy:
| Error Class | Status | Properties |
|---|---|---|
MonospaceError | any | message, status, source?, meta? |
MonospaceAuthError | 401 | default message: 'Authentication failed' |
MonospacePermissionError | 403 | default message: 'Permission denied' |
All error classes extend MonospaceError, which extends the native Error. The source property chains nested errors for debugging. The built-in client throws MonospaceAuthError for 401, MonospacePermissionError for 403, and a plain MonospaceError for every other status.
import {
MonospaceError,
MonospaceAuthError,
MonospacePermissionError,
} from '@monospace/sdk';
try {
await client.Articles.updateOne({
key: 1,
data: { status: 'published' },
fields: ['id'],
});
} catch (error) {
if (error instanceof MonospaceAuthError) {
// 401 — authentication failed
console.log(error.message);
} else if (error instanceof MonospacePermissionError) {
// 403 — insufficient permissions
console.log(error.message);
} else if (error instanceof MonospaceError) {
// Any other API error, such as 404 or 422
console.log(error.message, error.status, error.meta);
if (error.source) {
console.log('Caused by:', error.source.message);
}
}
}
See Also
- Client Setup — configure envelope behavior and other client options
- Type System — understand generated types and inference
- Reading Data — REST API reading patterns