Data Connectors

Data Connector API

Reference for the dataConnector entry, what setup and the handlers must do, the schema document, and the query requests a data connector receives and answers.
Preview
Extensions are in preview and can change in breaking ways between releases. Use them in non-production environments only. See Stability. Written for Monospace 1.0.0, @monospace/cli 0.2 and @monospace/extension-kit 0.3.

Declare the Entry

A data connector extension exposes an external data source, the API, service, or database it reads and writes, as collections in a Monospace workspace. A collection holds items, and each item carries named values called fields. Its entry in extension.config.json has "type": "dataConnector" and these fields:

FieldTypeRequiredRules
typestringYes"dataConnector"
idstringYesThe entry's identifier, in id format. A data source names it as its provider
versionstringYesThe entry's release, in version format. Independent of the extension version
namestringYesDisplay name. Must contain at least one non-whitespace character
descriptionstringNoDisplay description
iconstringNoIcon name. Defaults to lucide:cable. See Icons
iconForegroundstringNoGlyph color of a one-color icon. Independent of the extension's. See Icons
iconBackgroundstringNoTile color behind the icon. Independent of the extension's. See Icons
engineobjectYesHow the instance (your Monospace deployment) runs the entry. See below

A data source is a data connector configured in a workspace, with its own configuration and granted network permissions.

engine

FieldTypeRequiredRules
entrypointstringYesNon-empty. In the extension config, the source file to build; a relative path resolves against the project root. In the manifest, the built file, relative to the extension directory. See Entrypoint Rules
permissionsarrayNoHosts the entry needs on every data source. Omitted means none; the connector's preflight can still add hosts. See Network Permissions
capabilitiesobjectNo{ query: [...] }, the writes that answer with the written items: any of insertReturning, updateReturning and deleteReturning. Omitted means none. See Capabilities

Each permissions item is an object with exactly two fields:

FieldTypeRequiredRules
permissionstringYesStarts with net: followed by a target. See Target Syntax. The instance rejects the whole extension if it can't parse the target
reasonstringYesMust contain at least one non-whitespace character. Shown to whoever approves the grant

The instance also rejects the whole extension at load time when a permission target passes the schema but can't be parsed (e.g., net:example.com:notaport), or when a reason contains only whitespace.

Entrypoint Rules

monospace extension build replaces engine.entrypoint with the built file, named <entry id>.js. The instance checks each entrypoint in the manifest against the extension directory. It rejects:

  • absolute paths and Windows drive paths (e.g., C:/connector.js);
  • backslash separators;
  • any .. segment;
  • a path that isn't a file;
  • a symlink that resolves outside the extension directory.

A leading ./ and symlinks that stay inside the directory are accepted. One invalid entrypoint rejects the whole extension.

The entrypoint file must be one self-contained ES module whose default export has a setup function. monospace extension build checks for imports and a default export, but not for setup. A file without setup, or with a static import, fails when a data source starts. A dynamic import() inside a handler fails only when it runs. See One File, No Imports.

Import the Kit

@monospace/extension-kit has three import paths: /data-connector for defineDataConnector, its types and the error classes; /data-connector/protocol for the schema document and query types; and /data-connector/experimental for the schema helpers. The Extension Kit reference lists every export with its declaration.

The package has no runtime dependencies. The built entrypoint is a single file with no imports, so everything you use from the kit is bundled into it. See How Data Connectors Run.

Define a Data Connector

The entrypoint's default export is a descriptor. defineDataConnector builds one from a definition with a required setup and an optional preflight. The Stripe connector from Build a Stripe Data Connector parses its configuration in setup and returns its handlers; its other files hold the client, the schema and the operations:

defineDataConnector performs no I/O. It returns the definition plus a monospace marker, { kind: 'dataConnector', apiVersion: 1 }. Engine recognizes a descriptor by its setup function. A hand-written object with a setup function works without the kit.

Because the descriptor is a plain object, you can test a connector by calling setup and its handlers directly.

Receive the Configuration

preflight and setup each receive one argument, { config }. config is the data source's stored configuration object, passed through unchanged.

  • Nothing validates it before your code runs. Engine doesn't check the configuration against any schema. Check every value you depend on in setup, and throw InvalidConfiguration when one is missing or wrong.
  • Type it with the generic. defineDataConnector<Config> types config. The type is a compile-time assertion only. The default is Record<string, unknown>. The Stripe connector passes unknown and checks every value in parseConfig.
  • It's a bag on purpose. New keys can be added beside config later without breaking your connector.

preflight

preflight({ config }) is optional. It returns { permissions }: the network permissions this data source needs beyond the manifest's, derived from the configuration (e.g., a host taken from a baseUrl setting).

Each permission is { permission, reason }, where permission is net: followed by a target (see Target Syntax), and reason is the text shown to whoever approves the grant.

Neither example connector uses preflight: their hosts are fixed, so they declare them in the manifest.

  • Synchronous only. A preflight that returns a promise fails with "preflight must answer synchronously".
  • No side effects. Don't make requests, open connections, or build caches in preflight. It runs before setup, every time the connector starts.
  • Full validation belongs in setup. When a value preflight reads is invalid, throw InvalidConfiguration there. It deactivates the data source as it does from setup.

A permission is net: followed by a host, host:port, or *.host, never a URL. Each carries a reason, shown to whoever approves the grant. See Network Permissions.

setup

setup({ config }) runs once each time Engine starts the connector for a data source. It returns the handlers, or a promise of them.

  • Keep client state in the closure. No handler receives the configuration, so build your client for the external data source in setup and close over it.
  • It has a time limit. setup must settle within 30 seconds. See Data Connector Runtime Limits.
  • Throw InvalidConfiguration for bad configuration, and AccessDenied for credentials the external data source refuses. Engine deactivates the data source until the extension is reloaded, because retrying can't fix either. Requests that reach it fail with 422 "Invalid extension configuration", with your message nested inside.
  • A permission denial also deactivates it. A request in setup to a host outside the grant deactivates the data source when the denial reaches Engine, uncaught or kept as the cause of the error you throw.
  • Missing handlers also deactivate it. A setup that returns no object, or an object without introspect or query, deactivates the data source the same way.
  • Other throws don't deactivate the data source. Any other kit error class, or an error without one, fails the request that started the connector with 422 "Invalid extension configuration", and the next request starts it again.

See Data Connector Errors for the classes.

Handlers

setup returns an object of type DataConnectorHandlers, which lists each handler's signature:

HandlerRequiredPurpose
introspectYesDescribe the collections, fields and relations the data source exposes. Answers a SchemaDocument. See Describe the Schema
queryYesRun one operation against the external data source. A QueryHandler: receives the operation in a QueryRequest envelope, and answers { record } for a keyed operation and a QueryResultSerialized for the others. See Read a Query Request
testConnectionNoCheck that the external data source is reachable with this configuration. Answer { ok: true }, or throw to report a failure. Engine runs it on a connection test, and before it creates a data source or previews a schema
explainQueryNoReserved. Engine doesn't call it
  • Missing required handlers deactivate the data source. If setup returns something that isn't an object, or lacks introspect or query, the connector doesn't start until the extension is reloaded.
  • Unknown handlers are ignored. Engine logs "ignoring unrecognised handler(s)" with their names. Methods on a class prototype count.
  • testConnection is optional. Leave it out when every call to the external data source costs quota. A connection test against a connector without it answers 422 with code 7002, "The connector does not offer a connection test". Creating a data source and previewing a schema skip the test. { ok: false } is reported as a failed connection, and stops a create or a preview like a thrown error.
  • Handlers run concurrently. Engine doesn't wait for one request to finish before sending the next. Don't keep per-request state in the closure.
  • Every handler must return a value. A handler that returns undefined or null fails with "<handler> returned no value".

Describe the Schema

introspect answers with a SchemaDocument. Engine calls it when a data source is created or reintrospected, and stores the result. It also runs, without storing anything, when a configuration is previewed before a data source exists. Every section defaults to empty.

PropertyTypeDescription
namespacesCanonicalNamespace[]Named groups of collections. Each is { name, collections? }
collectionsCanonicalCollection[]Collections outside any namespace
relationsCanonicalRelation[]Links between collections

A collection's identity is its namespace (or none) plus its name, so the same collection name can appear in different namespaces.

Collections

PropertyTypeRequiredDescription
namestringYesThe name your connector uses. Every request you receive names the collection by it
apiNamestringNoThe name callers use. Engine derives one when absent. See Operations and Operators
fieldsCanonicalField[]NoThe collection's fields
indexesCanonicalIndex[]NoPrimary, unique and normal indexes
isReadonlybooleanYestrue blocks every create, update and delete. The helpers derive it: true exactly when operations declares no member that writes
operationsSupportedOperationsYesWhat the collection serves. {} serves nothing. See Operations and Operators

Fields

PropertyTypeRequiredDescription
namestringYesThe key your connector reads and writes on items
apiNamestringNoThe name callers use
typePrimitiveFieldTypeYesSee Field Types
isNullablebooleanYesWhether the value can be null
isListbooleanYesWhether the value is an array of type
defaultValueDefaultValueNoSee Defaults
isReadonlybooleanYestrue keeps the field out of create and update data. The helpers derive it: false exactly when a data list in operations names the field

Field Types

PrimitiveFieldType is a closed set. An unrecognized name rejects the schema. Only decimal and string take params.

typeValue on the wire
{ name: 'boolean' }JSON boolean
{ name: 'int8' }, int16, int32, uint8, uint16, uint32JSON number
{ name: 'int64' }, uint64Decimal string (e.g., "9007199254740993"), in requests and in REST responses. In an answer, Engine accepts a decimal string or a JSON number. See Values
{ name: 'float32' }, float64JSON number
{ name: 'decimal', params: { kind: 'unconstrained' } }, or { kind: 'constrained', precision, scale } for a declared precision and scaleString, to keep precision
{ name: 'string', params: { length: 'unlimited' } }String
{ name: 'string', params: { length: 'fixed', value } }String
{ name: 'uuid' }String
{ name: 'date' }, time, dateTime, dateTimeWithTimezoneString. See Data Types for the formats
{ name: 'bytes' }Base64 string
{ name: 'json' }Any JSON value
{ name: 'geometry' }Engine can't convert a non-null geometry value from a connector yet. Return null
{ name: 'unsupported' }Callers can't filter, write or read the field. Engine can still filter on it in its own requests, and passes a value you return for it through unconverted

A field with isList: true takes an array of values of its type, or null.

Indexes

CanonicalIndex is { name, fields, kind }, where kind is 'primary', 'unique' or 'normal' and fields lists field names in order.

The primary index decides which keyed operations a collection can offer. See Keys and the Stable ID.

Relations

CanonicalRelation links a constrained collection, which holds the fields that point at the other side, to a referenced collection.

Relation fields expose a relation to callers. A to-one relation field returns a single related item. A to-many relation field returns several. The field on the constrained collection is always to-one. The field on the referenced collection is to-one only when every constrained field has its own single-field primary or unique index; a composite unique index doesn't count.

PropertyTypeRequiredDescription
namestringYesThe relation's name
constrainedNamespacestringNoNamespace of the constrained collection
constrainedCollectionstringYesThe collection holding the pointing fields (e.g., articles)
constrainedFieldsstring[]YesThe pointing fields (e.g., ['authorId'])
constrainedRelationFieldNamestringNoName of the to-one relation field on the constrained collection (e.g., author). When absent, Engine derives it from referencedCollection
referencedNamespacestringNoNamespace of the referenced collection
referencedCollectionstringYesThe collection pointed at (e.g., authors)
referencedFieldsstring[]YesThe fields pointed at, paired by position (e.g., ['id'])
referencedRelationFieldNamestringNoName of the relation field on the referenced collection (e.g., articles). When absent, Engine derives it from constrainedCollection, and appends an s when a referenced field lacks its own single-field primary or unique index. That check differs from the one that makes the field to-many, so set the name explicitly
onDeleteReferentialActionYes'noAction', 'restrict', 'cascade', 'setNull' or 'setDefault'
onUpdateReferentialActionYesSame values as onDelete

Each relation field is only offered when the collections' operations can serve it. See Relations.

Defaults

DefaultValue describes a field's default. The kit never evaluates it.

ShapeMeaning
{ source: 'connectorManaged', isAutoincrement?: boolean }Your connector or the external data source supplies the value
{ source: 'engineManaged', onCreate?, onUpdate? }Each of onCreate and onUpdate is { kind: 'currentTimestamp' }, { kind: 'currentUser' }, { kind: 'randomUuid', version: 'v4' | 'v7' } or { kind: 'static', valueEncoded }

Engine evaluates engineManaged defaults before calling your connector. When a caller omits a field that the operation's input offers, Engine fills in a value. A create uses onCreate, and an update uses onUpdate. A field outside the member's data gets no engine-managed value, and neither does a foreign key whose relation input isn't offered; see Foreign Keys Are Set Through the Relation on Create. currentUser is null when the request has no current user.

Engine checks each default when the data source is created or reintrospected. A list field can't have one. An engineManaged default needs onCreate or onUpdate, and a field type other than unsupported. currentUser and randomUuid need a uuid field, currentTimestamp a date or time field, and a static value must fit the field's type.

A read-only, non-nullable field without a default blocks create on its collection. See Declaration Rules. With the helpers, a field is read-only unless a data list names it, so defineCollection rejects a create that leaves such a field out of every data list.

Example Schema Document

The full Art Institute connector, which Map an External Data Source walks through, returns this document from introspect. It's shown for the artists collection and the relation; the artworks collection is left out. The connector builds it with the helpers, and a hand-written SchemaDocument has the same shape:

operators and logicalOperators are maps from each operator to its options. No operator has options yet, so each value is {}. The helpers filter.field and filter.logical build these maps for you.

Build a Schema with the Helpers

@monospace/extension-kit/data-connector/experimental builds the same SchemaDocument from typed definitions, checks them, and derives the isReadonly flags from operations. The helpers are optional, and experimental within the experimental kit. Schema Helpers documents each helper, the checks it runs, and the Art Institute and Stripe schemas built with them.

Read a Query Request

Engine calls query with an envelope that holds one operation (QuerySerialized). The handler receives it as a QueryRequest, and a function typed with QueryRequest<'readOne'> receives only that operation:

request.json
{
  "query": {
    "op": "readMany",
    "collection": "articles",
    "select": ["id", "title", "wordCount"],
    "filter": { "cmp": "greaterThan", "left": { "field": "wordCount" }, "right": { "value": 1000 } },
    "sort": [{ "field": "title", "direction": "ascending" }],
    "limit": 100,
    "offset": 0
  }
}

Every operation carries op, collection, and namespace when the collection is in one. Collections and fields are named by their name, never their apiName.

Operations

opOther propertiesAnswer with
readManyselect?, filter?, sort?, limit?, offset?, count?records: the matching items, or totalCount on a count read
readOneselect, key, filter?record: the item, or null
createselect, data (an array, one object per item to create)records: the created items
updateManyselect, filter?, data (one object applied to every match)records: the updated items
updateOneselect, key, filter?, datarecord: the updated item, or null
deleteManyselect, filter?records: the deleted items
deleteOneselect, key, filter?record: the deleted item, or null
  • select lists the fields each returned item must carry.
  • The query capabilities decide what a write selects. Without insertReturning, create arrives with select set to the stable-ID fields only, whatever the caller selected; answer each created item with them. Without updateReturning or deleteReturning, updates and deletes arrive as updateMany and deleteMany with select: [], filtered by the stable IDs Engine read first. See Declare Query Capabilities.
  • Engine sends one create per item. data holds one object.
  • data objects are keyed by field name and hold only fields the member's data declares.
  • An absent filter adds no condition. A keyed operation still targets only its key.
  • An absent limit means no limit. An absent offset means 0.

Keyed Operations

readOne, updateOne and deleteOne (KeyedOperation) address one item by key, an object keyed by the collection's primary-key field. Engine offers keyed operations only on a single-field primary key, so key has one entry.

request.json
{
  "query": {
    "op": "updateOne",
    "collection": "articles",
    "key": { "id": "art_42" },
    "filter": { "cmp": "equals", "left": { "field": "status" }, "right": { "value": "draft" } },
    "data": { "status": "published" },
    "select": ["id", "status"]
  }
}
  • filter is a guard. The item must match both the key and the guard. Otherwise change nothing.
  • Answer record. Answer { "record": <item> } with the item the operation read, updated or deleted, carrying every field in select. The answer takes no other key.
  • Answer record: null on a miss. When the key matches no item, or the item fails the guard, answer { "record": null } and change nothing. A miss isn't an error, so don't throw. Callers see 404 "No record found".
  • Never answer records. Engine fails a keyed operation that answers { "records": [...] }, whatever it holds, with 500 "Invalid extension response" and the reason "query answered with something this protocol does not define: unknown field records, expected record". A write your connector made stays applied.
  • Keyed writes need their capability. Engine sends updateOne only to a connector that declares updateReturning, and deleteOne only with deleteReturning. Without it, the caller's keyed write reaches your connector as updateMany or deleteMany by stable ID.
response.json
{ "record": { "id": "art_42", "status": "published" } }

See Keys and the Stable ID.

Count Reads

When a caller asks for the total count, Engine first derives it from the page it read. A page shorter than limit, or a read without limit, establishes the total, unless it's empty at a non-zero offset.

Otherwise Engine sends a separate readMany with count: true, limit: 0 and no select. Recognize it by count: true: an ordinary read with an empty selection also arrives without select. Answer with totalCount, the number of items matching filter, ignoring limit and offset. Return no items.

response.json
{ "totalCount": 1284 }

If you omit totalCount, the caller's response carries no total.

Filters

FilterSerialized is an untagged union. Each node has exactly one of these shapes:

ShapeMeaning
{ and: FilterSerialized[] }Every child matches
{ or: FilterSerialized[] }At least one child matches
{ not: FilterSerialized }The child doesn't match
{ cmp: FilterOperator, left, right }A comparison. cmp is equals, greaterThan, greaterThanOrEquals, lessThan, lessThanOrEquals, contains, icontains, startsWith or endsWith
{ in: operand, values }The operand is one of values. values is an array of literals, or { field } naming a list field

Each operand is { field: name } or { value: Value }. Read both sides of a comparison; don't assume the field is on the left.

filter.json
{
  "and": [
    { "in": { "field": "status" }, "values": ["published", "featured"] },
    { "not": { "cmp": "equals", "left": { "field": "publishedAt" }, "right": { "value": null } } }
  ]
}

Negated caller operators arrive as not around the positive form (e.g., _neq as not around equals). _between arrives as and of greaterThanOrEquals and lessThanOrEquals. See Operations and Operators.

Sort

sort is an array of SortItemSerialized, applied in order:

PropertyTypeDescription
fieldstringThe field to sort by
direction'ascending' | 'descending'The direction
nulls'first' | 'last'Optional. Places nulls before or after every value, whatever the direction. Engine sends it only when the caller sets it. Absent, your connector places nulls by its own default

Values

Value is untagged JSON: null, string, number, boolean, array, or object. Types without a JSON form of their own travel as strings: decimals, base64 bytes, UUIDs, dates and times. The table in Field Types lists each encoding.

int64 and uint64 values arrive as decimal strings, in data, in filter literals and in keys, so values beyond JavaScript's safe-integer range stay exact. In an answer, return a decimal string or a number. REST responses carry them as strings too. Narrower integers arrive as JSON numbers, and so do the elements of a list field: only single values become strings. An int64 or uint64 list element beyond JavaScript's safe-integer range loses precision when your connector receives it.

Warning
An int64 or uint64 key arrives as a string. A check such as typeof id === 'number' on it misses every item. Declare int32 for ids that fit in 32 bits, or read the key as a string.

Answer a Query

A keyed operation answers { record } (see Keyed Operations). Every other operation answers with a QueryResultSerialized:

PropertyTypeDescription
recordsMonospaceObject[]The items, keyed by field name. Absent means none
totalCountnumberThe answer to a count read. A non-negative integer
response.json
{
  "records": [
    { "id": "art_42", "title": "Designing Filters", "wordCount": 1850 },
    { "id": "art_43", "title": "Paging Large Collections", "wordCount": null }
  ]
}

Engine checks the fields of every item in records, and of a keyed answer's record, the same way. An answer that fails a check fails the request with 500 "Invalid extension response", with the reason nested inside:

  • Carry every selected field. Use an explicit null for a missing value. A missing field fails the request with "Missing selected field".
  • Extra fields on an item are ignored. Engine keeps only the fields in select. A non-keyed answer takes only records and totalCount, and any other key fails the request.
  • Match the declared type. Engine converts each value by its field's type and fails the request when it can't, for example "Expected a value matching Int64, got a string" for "n/a".
  • Some limits aren't checked. Engine rejects a negative value in an unsigned field and a value outside the 64-bit range. It doesn't check narrower integer widths such as int8, string length or nullability.
  • Writes aren't rolled back. When your answer fails one of these checks, a write your connector already made stays applied. See No Transactions.
  • Fill the page. Engine reads fewer items than limit as the end of the data: the caller sees no next page. When the external data source returns short pages, keep fetching until you reach limit or it runs out of items.
  • Answers aren't streamed. A non-keyed answer arrives as one records array, and Engine converts every item in it before using any. Engine holds the whole answer in memory, so bound what a read without limit fetches. When a read hits your bound, throw QueryRejected instead of returning the items fetched so far: Engine treats every answer to a read without limit as complete.

Handle Requests You Didn't Declare

Engine offers callers only what your declaration lists, and rejects a caller's request outside it. It doesn't check the filters it sends to your connector against the called member's declaration. So some requests reach your connector in a shape the declaration doesn't list. Some come from caller shorthands and defaults that Engine expands, and others are calls Engine makes on its own. Serve them where you can. For a request you can't serve, throw QueryRejected, which fails it with 422 and passes your message to the caller.

A comparison on a field or with an operator the called member never declared can reach your connector too:

  • A permission rule's condition. A field read rule's condition joins the filter of every call whose filter names the field it guards, including bulk updates and deletes. Saving the policy checks the condition against readMany, readOne, updateOne and deleteOne only, so it reaches updateMany and deleteMany unchecked. See Permission Rules.
  • A stored declaration older than your code. After an extension update that narrowed the declaration without a schema refresh, Engine still offers what the stored declaration lists.

Never drop a comparison you can't translate: it can be a permission rule that restricts which items the caller may touch. Declare the fields and operators your rules' conditions use in every member they reach, and refuse any other undeclared comparison. No error class fits it, so throw a plain Error, which Engine reports as a failed query.

To catch them, check each request against your own declaration: the full Art Institute connector in Map an External Data Source reads it from schema.toSchemaDocument(). Engine's relation and read-back lookups on a key no declaration can carry are the exception: serve them.

You receiveWhen
A default limit on a readManyThe caller set no limit, even if readMany declares no limit. The value is MONOSPACE_QUERY_LIMIT_DEFAULT, 100 by default
No limitThe caller asked for no limit (limit=-1 or limit=0) and the instance sets no MONOSPACE_QUERY_LIMIT_MAX, or the caller set no limit and the instance unsets its default
A count read (count: true, limit: 0, no select)The caller asked for the total count and the page didn't establish it. See Count Reads
equals with a null valueThe caller used _null: true, on a nullable field that declares equals, in a filter that declares not. _null: false arrives as not around it
equals from shorthandThe caller wrote { field: value }, on a non-json field that declares equals
direction: 'ascending'The caller sorted without a direction, on a field that declares ascending. A field that declares only descending requires a direction. Any sorted field can also carry nulls
One readMany with in over the relation's key field and no limitThe caller includes a to-one relation field, or a to-many relation field that resolves to no limit and no offset. See Relation Reads
One readMany per distinct parent key, up to 16 at a timeThe caller includes a to-many relation field that resolves to a limit or an offset. With the default instance settings, that's every to-many include except one with limit=-1 or limit=0 and no offset. See Relation Reads
The relation filter shape on a key no declaration can offer it onA relation key or stable ID of one field of type boolean, bytes, json, geometry or unsupported receives in. One of several fields, at least one of them unsupported, receives equals on each. It arrives in relation reads and read-backs. See Rules for Engine's Own Calls
A readMany by stable ID after a writeEngine reads back what a create or an update wrote when the connector doesn't declare insertReturning or updateReturning. After an update, it reads the stable IDs first, then the caller's selection. See Declare Query Capabilities
A readMany before a writeEngine reads the stable IDs of the items an update or delete matches when the connector doesn't declare its capability. Before a delete, it then reads the caller's selection by those stable IDs. It also re-reads what a write filters to check a caller's update rules. The rules arrive wrapped in not and joined to the update's filter with and
updateMany or deleteMany with select: [], filtered by stable IDsThe caller updated or deleted items, and the connector doesn't declare updateReturning or deleteReturning
An or of permission rules, joined to the caller's filter with andSeveral of the caller's policies grant access to the collection with a filter, and none grants it unconditionally
A key on every readOne, updateOne and deleteOneAlways, whatever the member's filter declares

Engine doesn't add a sort of its own. A request without a caller sort arrives without sort, so keep your connector's default order stable across pages.

Relation Reads

Engine resolves a relation field by reading the other collection, filtered by the relation's key field. It sends that read to your connector in one of two shapes:

  • Batched: one readMany with in over the key values of every parent item, joined with and to the include's filter, with no limit and no offset.
  • Per parent: one readMany per distinct, non-null parent key. Engine sends up to 16 of them at a time for one include, so they arrive concurrently. Each carries in with that one key value, joined with and to the include's filter, plus the include's sort and the resolved limit and offset.

For a key of several fields, or a key field whose filter declares equals and or but no in, equals on each field takes the place of in. See Resolve Relations.

A to-one include is always batched, because it takes no limit or offset. A to-many include is batched only when it resolves to no limit and no offset. A data connector can't apply a limit or offset to each parent's group of items within one read, so every other to-many include is read per parent.

The include's limit resolves like a top-level read's (see Page Size). The instance settings are MONOSPACE_QUERY_LIMIT_DEFAULT (the default, 100 unless configured) and MONOSPACE_QUERY_LIMIT_MAX (the maximum, unset unless configured):

Include limitInstance settingsWithout offsetWith offset
OmittedDefault set (100 unless configured)Per parent, limit = the defaultPer parent, limit = the default, and the offset
OmittedDefault 0Per parent, limit: 0, so no related itemsPer parent, limit: 0, and the offset
OmittedDefault unset (query_limit_default: null in config.yaml)BatchedPer parent, no limit, and the offset
PositiveNo maximum, or a maximum at least the include's limitPer parent, the include's limitPer parent, the include's limit and offset
PositiveA maximum below the include's limitRejected with HTTP 422Rejected with HTTP 422
-1 or 0No maximumBatchedPer parent, no limit, and the offset
-1 or 0Maximum setPer parent, limit = the maximumPer parent, limit = the maximum, and the offset
Below -1AnyRejectedRejected
  • To-many includes are paged by default. With the default instance settings, a to-many include without limit carries limit: 100, so your connector receives one read per parent, and a caller sees at most 100 related items per parent.
  • The default isn't checked against the maximum. With a default of 100 and a maximum of 50, an include without limit still arrives with limit: 100.
  • A caller can set limit or offset on an include only when the related collection's readMany declares it. Otherwise the include always resolves as an omitted limit without offset.
  • Only config.yaml can unset the default. MONOSPACE_QUERY_LIMIT_DEFAULT accepts only an integer; an empty value stops the instance from starting.
  • A key of several fields, or a single key field whose filter declares equals and or but no in, arrives as an or of and-joined equals instead of in.
  • Per-parent reads add up. A to-many include on a page of 100 parent items can send your connector up to 100 reads, up to 16 at a time. Concurrent caller requests each send their own.

Declare the filter each direction needs. See Resolve Relations.

See Also

MonospaceThe governed API layer for every app, person, and agent.

Copyright © 2026 Monospace Inc.