Data Connector API
@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:
| Field | Type | Required | Rules |
|---|---|---|---|
type | string | Yes | "dataConnector" |
id | string | Yes | The entry's identifier, in id format. A data source names it as its provider |
version | string | Yes | The entry's release, in version format. Independent of the extension version |
name | string | Yes | Display name. Must contain at least one non-whitespace character |
description | string | No | Display description |
icon | string | No | Icon name. Defaults to lucide:cable. See Icons |
iconForeground | string | No | Glyph color of a one-color icon. Independent of the extension's. See Icons |
iconBackground | string | No | Tile color behind the icon. Independent of the extension's. See Icons |
engine | object | Yes | How 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
| Field | Type | Required | Rules |
|---|---|---|---|
entrypoint | string | Yes | Non-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 |
permissions | array | No | Hosts the entry needs on every data source. Omitted means none; the connector's preflight can still add hosts. See Network Permissions |
capabilities | object | No | { 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:
| Field | Type | Required | Rules |
|---|---|---|---|
permission | string | Yes | Starts with net: followed by a target. See Target Syntax. The instance rejects the whole extension if it can't parse the target |
reason | string | Yes | Must 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:
import { defineDataConnector } from '@monospace/extension-kit/data-connector';
import { StripeClient } from './client';
import { schemaDocument } from './collections';
import { parseConfig } from './config';
import { readMany, readOne } from './read';
import { findResource } from './resources';
import { create, deleteMany, deleteOne, updateMany, updateOne } from './write';
export default defineDataConnector<unknown>({
setup({ config: rawConfig }) {
const config = parseConfig(rawConfig);
const context = { client: new StripeClient(config.apiKey), config };
return {
introspect() {
return schemaDocument(config.enableWrites);
},
async query({ query }) {
// Engine sends only declared collections, and this connector declares no namespaces.
// For anything else, throw a plain error.
const resource = query.namespace === undefined ? findResource(query.collection) : undefined;
if (resource === undefined) throw new Error(`Unknown collection \`${query.collection}\`.`);
switch (query.op) {
case 'readMany': return await readMany(context, resource, query);
case 'readOne': return await readOne(context, resource, query);
case 'create': return await create(context, resource, query);
case 'updateMany': return await updateMany(context, resource, query);
case 'updateOne': return await updateOne(context, resource, query);
case 'deleteMany': return await deleteMany(context, resource, query);
case 'deleteOne': return await deleteOne(context, resource, query);
default: {
// `query` is `never` here, so an operation added to the kit fails to compile until it's handled.
const unhandled: never = query;
throw new Error('The Stripe connector received an operation it doesn\'t handle.', { cause: unhandled });
}
}
},
async testConnection() {
await context.client.ping();
return { ok: true };
},
};
},
});
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 throwInvalidConfigurationwhen one is missing or wrong. - Type it with the generic.
defineDataConnector<Config>typesconfig. The type is a compile-time assertion only. The default isRecord<string, unknown>. The Stripe connector passesunknownand checks every value inparseConfig. - It's a bag on purpose. New keys can be added beside
configlater 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
preflightthat 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 beforesetup, every time the connector starts. - Full validation belongs in
setup. When a valuepreflightreads is invalid, throwInvalidConfigurationthere. It deactivates the data source as it does fromsetup.
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
setupand close over it. - It has a time limit.
setupmust settle within 30 seconds. See Data Connector Runtime Limits. - Throw
InvalidConfigurationfor bad configuration, andAccessDeniedfor 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 with422"Invalid extension configuration", with your message nested inside. - A permission denial also deactivates it. A request in
setupto a host outside the grant deactivates the data source when the denial reaches Engine, uncaught or kept as thecauseof the error you throw. - Missing handlers also deactivate it. A
setupthat returns no object, or an object withoutintrospectorquery, 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:
| Handler | Required | Purpose |
|---|---|---|
introspect | Yes | Describe the collections, fields and relations the data source exposes. Answers a SchemaDocument. See Describe the Schema |
query | Yes | Run 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 |
testConnection | No | Check 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 |
explainQuery | No | Reserved. Engine doesn't call it |
- Missing required handlers deactivate the data source. If
setupreturns something that isn't an object, or lacksintrospectorquery, 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.
testConnectionis optional. Leave it out when every call to the external data source costs quota. A connection test against a connector without it answers422with code7002, "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
undefinedornullfails 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.
| Property | Type | Description |
|---|---|---|
namespaces | CanonicalNamespace[] | Named groups of collections. Each is { name, collections? } |
collections | CanonicalCollection[] | Collections outside any namespace |
relations | CanonicalRelation[] | 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
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The name your connector uses. Every request you receive names the collection by it |
apiName | string | No | The name callers use. Engine derives one when absent. See Operations and Operators |
fields | CanonicalField[] | No | The collection's fields |
indexes | CanonicalIndex[] | No | Primary, unique and normal indexes |
isReadonly | boolean | Yes | true blocks every create, update and delete. The helpers derive it: true exactly when operations declares no member that writes |
operations | SupportedOperations | Yes | What the collection serves. {} serves nothing. See Operations and Operators |
Fields
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The key your connector reads and writes on items |
apiName | string | No | The name callers use |
type | PrimitiveFieldType | Yes | See Field Types |
isNullable | boolean | Yes | Whether the value can be null |
isList | boolean | Yes | Whether the value is an array of type |
defaultValue | DefaultValue | No | See Defaults |
isReadonly | boolean | Yes | true 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.
type | Value on the wire |
|---|---|
{ name: 'boolean' } | JSON boolean |
{ name: 'int8' }, int16, int32, uint8, uint16, uint32 | JSON number |
{ name: 'int64' }, uint64 | Decimal 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' }, float64 | JSON number |
{ name: 'decimal', params: { kind: 'unconstrained' } }, or { kind: 'constrained', precision, scale } for a declared precision and scale | String, to keep precision |
{ name: 'string', params: { length: 'unlimited' } } | String |
{ name: 'string', params: { length: 'fixed', value } } | String |
{ name: 'uuid' } | String |
{ name: 'date' }, time, dateTime, dateTimeWithTimezone | String. 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.
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The relation's name |
constrainedNamespace | string | No | Namespace of the constrained collection |
constrainedCollection | string | Yes | The collection holding the pointing fields (e.g., articles) |
constrainedFields | string[] | Yes | The pointing fields (e.g., ['authorId']) |
constrainedRelationFieldName | string | No | Name of the to-one relation field on the constrained collection (e.g., author). When absent, Engine derives it from referencedCollection |
referencedNamespace | string | No | Namespace of the referenced collection |
referencedCollection | string | Yes | The collection pointed at (e.g., authors) |
referencedFields | string[] | Yes | The fields pointed at, paired by position (e.g., ['id']) |
referencedRelationFieldName | string | No | Name 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 |
onDelete | ReferentialAction | Yes | 'noAction', 'restrict', 'cascade', 'setNull' or 'setDefault' |
onUpdate | ReferentialAction | Yes | Same 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.
| Shape | Meaning |
|---|---|
{ 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:
{
"collections": [
{
"name": "artists",
"isReadonly": true,
"fields": [
{
"name": "id",
"type": {
"name": "int32"
},
"isNullable": false,
"isList": false,
"isReadonly": true
},
{
"name": "title",
"type": {
"name": "string",
"params": {
"length": "unlimited"
}
},
"isNullable": false,
"isList": false,
"isReadonly": true
},
{
"name": "birthDate",
"type": {
"name": "int32"
},
"isNullable": true,
"isList": false,
"isReadonly": true
},
{
"name": "deathDate",
"type": {
"name": "int32"
},
"isNullable": true,
"isList": false,
"isReadonly": true
}
],
"indexes": [
{
"name": "artists_pkey",
"kind": "primary",
"fields": [
"id"
]
}
],
"operations": {
"readMany": {
"filter": {
"fields": {
"id": {
"operators": {
"equals": {},
"in": {}
}
},
"birthDate": {
"operators": {
"equals": {},
"in": {},
"lessThan": {},
"lessThanOrEquals": {},
"greaterThan": {},
"greaterThanOrEquals": {}
}
}
},
"logicalOperators": {
"and": {},
"or": {}
}
},
"sort": {
"id": {
"directions": [
"ascending",
"descending"
]
},
"birthDate": {
"directions": [
"ascending",
"descending"
]
}
},
"limit": {},
"offset": {},
"output": [
"id",
"title",
"birthDate",
"deathDate"
],
"meta": {
"totalCount": true
}
},
"readOne": {
"filter": {
"fields": {
"id": {
"operators": {
"equals": {},
"in": {}
}
},
"birthDate": {
"operators": {
"equals": {},
"in": {},
"lessThan": {},
"lessThanOrEquals": {},
"greaterThan": {},
"greaterThanOrEquals": {}
}
}
},
"logicalOperators": {
"and": {},
"or": {}
}
},
"output": [
"id",
"title",
"birthDate",
"deathDate"
]
}
}
}
],
"relations": [
{
"name": "artworks_artist",
"constrainedCollection": "artworks",
"constrainedFields": [
"artistId"
],
"constrainedRelationFieldName": "artist",
"referencedCollection": "artists",
"referencedFields": [
"id"
],
"referencedRelationFieldName": "artworks",
"onDelete": "noAction",
"onUpdate": "noAction"
}
]
}
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:
{
"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
op | Other properties | Answer with |
|---|---|---|
readMany | select?, filter?, sort?, limit?, offset?, count? | records: the matching items, or totalCount on a count read |
readOne | select, key, filter? | record: the item, or null |
create | select, data (an array, one object per item to create) | records: the created items |
updateMany | select, filter?, data (one object applied to every match) | records: the updated items |
updateOne | select, key, filter?, data | record: the updated item, or null |
deleteMany | select, filter? | records: the deleted items |
deleteOne | select, key, filter? | record: the deleted item, or null |
selectlists the fields each returned item must carry.- The query capabilities decide what a write selects. Without
insertReturning,createarrives withselectset to the stable-ID fields only, whatever the caller selected; answer each created item with them. WithoutupdateReturningordeleteReturning, updates and deletes arrive asupdateManyanddeleteManywithselect: [], filtered by the stable IDs Engine read first. See Declare Query Capabilities. - Engine sends one
createper item.dataholds one object. dataobjects are keyed by field name and hold only fields the member'sdatadeclares.- An absent
filteradds no condition. A keyed operation still targets only itskey. - An absent
limitmeans no limit. An absentoffsetmeans0.
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.
{
"query": {
"op": "updateOne",
"collection": "articles",
"key": { "id": "art_42" },
"filter": { "cmp": "equals", "left": { "field": "status" }, "right": { "value": "draft" } },
"data": { "status": "published" },
"select": ["id", "status"]
}
}
filteris 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 inselect. The answer takes no other key. - Answer
record: nullon 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 see404"No record found". - Never answer
records. Engine fails a keyed operation that answers{ "records": [...] }, whatever it holds, with500"Invalid extension response" and the reason "queryanswered with something this protocol does not define: unknown fieldrecords, expectedrecord". A write your connector made stays applied. - Keyed writes need their capability. Engine sends
updateOneonly to a connector that declaresupdateReturning, anddeleteOneonly withdeleteReturning. Without it, the caller's keyed write reaches your connector asupdateManyordeleteManyby stable ID.
{ "record": { "id": "art_42", "status": "published" } }
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.
{ "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:
| Shape | Meaning |
|---|---|
{ 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.
{
"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:
| Property | Type | Description |
|---|---|---|
field | string | The 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.
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:
| Property | Type | Description |
|---|---|---|
records | MonospaceObject[] | The items, keyed by field name. Absent means none |
totalCount | number | The answer to a count read. A non-negative integer |
{
"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
nullfor 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 onlyrecordsandtotalCount, 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
limitas the end of the data: the caller sees no next page. When the external data source returns short pages, keep fetching until you reachlimitor it runs out of items. - Answers aren't streamed. A non-keyed answer arrives as one
recordsarray, and Engine converts every item in it before using any. Engine holds the whole answer in memory, so bound what a read withoutlimitfetches. When a read hits your bound, throwQueryRejectedinstead of returning the items fetched so far: Engine treats every answer to a read withoutlimitas 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,updateOneanddeleteOneonly, so it reachesupdateManyanddeleteManyunchecked. 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 receive | When |
|---|---|
A default limit on a readMany | The caller set no limit, even if readMany declares no limit. The value is MONOSPACE_QUERY_LIMIT_DEFAULT, 100 by default |
No limit | The 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 value | The 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 shorthand | The 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 limit | The 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 time | The 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 on | A 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 write | Engine 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 write | Engine 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 IDs | The 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 and | Several 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 deleteOne | Always, 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
readManywithinover the key values of every parent item, joined withandto the include'sfilter, with nolimitand nooffset. - Per parent: one
readManyper distinct, non-null parent key. Engine sends up to 16 of them at a time for one include, so they arrive concurrently. Each carriesinwith that one key value, joined withandto the include'sfilter, plus the include'ssortand the resolvedlimitandoffset.
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 limit | Instance settings | Without offset | With offset |
|---|---|---|---|
| Omitted | Default set (100 unless configured) | Per parent, limit = the default | Per parent, limit = the default, and the offset |
| Omitted | Default 0 | Per parent, limit: 0, so no related items | Per parent, limit: 0, and the offset |
| Omitted | Default unset (query_limit_default: null in config.yaml) | Batched | Per parent, no limit, and the offset |
| Positive | No maximum, or a maximum at least the include's limit | Per parent, the include's limit | Per parent, the include's limit and offset |
| Positive | A maximum below the include's limit | Rejected with HTTP 422 | Rejected with HTTP 422 |
-1 or 0 | No maximum | Batched | Per parent, no limit, and the offset |
-1 or 0 | Maximum set | Per parent, limit = the maximum | Per parent, limit = the maximum, and the offset |
Below -1 | Any | Rejected | Rejected |
- To-many includes are paged by default. With the default instance settings, a to-many include without
limitcarrieslimit: 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
100and a maximum of50, an include withoutlimitstill arrives withlimit: 100. - A caller can set
limitoroffseton an include only when the related collection'sreadManydeclares it. Otherwise the include always resolves as an omittedlimitwithoutoffset. - Only
config.yamlcan unset the default.MONOSPACE_QUERY_LIMIT_DEFAULTaccepts only an integer; an empty value stops the instance from starting. - A key of several fields, or a single key field whose filter declares
equalsandorbut noin, arrives as anorofand-joinedequalsinstead ofin. - 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
- Extension Config and Manifest: the extension-level fields around the entry
- Operations and Operators: declare what each collection serves and which rules Engine enforces
- Data Connector Errors: error classes and when to throw each
- Extension Kit: every export of the kit, with its declaration
- Network Permissions:
net:permissions in the manifest and frompreflight - How Data Connectors Run: the sandbox your connector runs in
Schema Helpers
Reference for every export of @monospace/extension-kit/data-connector/experimental, the helpers that build a data connector's schema document from typed definitions.
Operations and Operators
Reference for the operations a data connector collection declares, the filter operators each field type allows, and the rules Engine enforces on a declaration.