Build a Stripe Data Connector
@monospace/cli 0.2 and @monospace/extension-kit 0.3. Overview
This guide builds a data connector extension for Stripe in test mode. The connector exposes the Stripe API, its external data source, as three collections in a Monospace workspace. Each item in customers is one Stripe customer, with fields such as email.
Stripe is a harder case than the Quickstart's Art Institute API: it has no filter language, needs a secret key, and accepts writes. The same patterns serve any external data source with a weak grammar.
| Collection | Reads | Writes (with enableWrites) |
|---|---|---|
customers | Filter by id, email and created; newest first | Create, update, delete |
products | Filter by id, active and created; newest first | Create (optionally with your own id), update, delete |
prices | Filter by id, productId, active, type and created; newest first | Create; update active and nickname only. Stripe can't delete prices |
A relation links prices.productId to products.id. A price has a to-one product field, which returns a single related item, and a product has a to-many prices field, which returns several.
stripe-connector.zip): the eleven source files, the unit tests, and the seed and smoke scripts.This guide explains the decisions behind those files and shows only the code that carries a Stripe-specific decision.
Prerequisites
| Requirement | Check |
|---|---|
| A Stripe test-mode secret key | The key starts with sk_test_ |
| A self-hosted Monospace instance (a running Monospace deployment) whose extensions install location you can write to | See Install and Manage Extensions |
| Node.js 22.12 or later and a package manager | node --version |
| A Monospace API key for a user who can create data sources and read and write items, such as a workspace Administrator | Create one under Account Settings > Access in Studio. See Get an API Key |
Build the Data Connector Quickstart first if you haven't built a connector before. This guide explains its concepts only briefly.
Code blocks on this page are excerpts of the files in the download, and // … marks the lines an excerpt leaves out. Each step ends with a Done when list: run those checks against the complete project.
Query results are cached. When you compare a response with Stripe after a change, send Cache-Control: no-cache.
1. Probe the Stripe API
Design against what Stripe does, not what its reference says. Call every endpoint and parameter you might rely on, and record what comes back:
curl -g "https://api.stripe.com/v1/customers?limit=3&offset=5" \
-H "Authorization: Bearer sk_test_YOUR_STRIPE_KEY" \
-H "Stripe-Version: 2026-08-26.dahlia"
Probe each filter with a value that must match nothing, and probe unknown parameters, sorts, page sizes, counts and error paths. The connector's design rests on these results:
| Probe | Result |
|---|---|
customers?sort=created, customers?foo=1 | Refused. Stripe rejects unknown parameters, and lists return newest created first with no other order |
limit=101 | 100 objects and has_more: true, no error. The page size is capped at 100 without warning |
customers?limit=3&offset=5 | Objects 6 to 8. Stripe's list endpoints also accept an undocumented offset parameter; its reference documents only cursors |
include[]=total_count | Refused. Lists have no total count |
email=ada@… vs. email=ADA@… | 1 vs. 0 customers. The email filter is exact and case-sensitive |
created[gte]=t&created[lte]=t | Inclusive, in whole seconds |
prices?type=bogus | HTTP 400. Enum parameters reject values outside their domain |
prices?product=prod_does_not_exist | HTTP 400, code: resource_missing, param: product: an error, not an empty list |
GET customers/cus_missing | HTTP 404, code: resource_missing, param: id |
DELETE products/{id} for a product with prices | HTTP 400, type: invalid_request_error, with no code and no param |
| A wrong key | HTTP 401. The message echoes the key's last characters |
| A 400 or 404 error body | Carries a request_log_url, which names the Stripe account |
| A deleted customer by id | Still returned, with deleted: true |
Turn the results into a capability table. Every filter operator, sort and paging argument you offer names the Stripe request that answers it. If nothing answers it, you don't offer it.
| Collection | Offered | Stripe request |
|---|---|---|
| all | id: equals, in | GET /{collection}/{id}, one per id |
| all | created: equals, lessThan[OrEquals], greaterThan[OrEquals] | created[gte], created[lte] in whole seconds |
| all | sort created descending; limit, offset | the list endpoint, paged by 100 |
customers | email: equals, in | ?email=…, one request per value |
products | active: equals | ?active=true or ?active=false |
prices | productId: equals, in; active: equals; type: equals, in | ?product=…, ?active=…, ?type=… |
Filters on name, phone, unitAmount and most other fields aren't in the table. Stripe has no list parameter for them, and answering one would mean reading every item. Total counts, other sort orders and negation are out for the same reason.
Done when every entry in the table names a Stripe request you ran, and you know which entries match exactly. See Map an External Data Source for the full probing checklist and the Fit Check.
2. Set Up the Project
Unzip the download and install its dependencies, or scaffold a project with the CLI and add the files yourself:
npx @monospace/cli@0.2 extension create ./stripe-connector --id example/stripe --name Stripe
pnpx @monospace/cli@0.2 extension create ./stripe-connector --id example/stripe --name Stripe
yarn dlx @monospace/cli@0.2 extension create ./stripe-connector --id example/stripe --name Stripe
bunx @monospace/cli@0.2 extension create ./stripe-connector --id example/stripe --name Stripe
The CLI prints Created the Stripe extension and two Next steps. Run the first, which installs the dependencies. The scaffold puts its source in src/data-connectors/example/: delete that directory, because this connector's source goes in src/data-connectors/stripe/.
Give the entry its own id, so a second extension from the same namespace doesn't collide with it. This is the finished extension config:
{
"$schema": "./node_modules/@monospace/cli/schemas/extension.config.schema.json",
"id": "example/stripe",
"version": "0.1.0",
"name": "Stripe",
"icon": "simple-icons:stripe",
"iconBackground": "#635BFF",
"iconForeground": "#ffffff",
"entries": [
{
"type": "dataConnector",
"id": "example/stripe-connector",
"version": "0.1.0",
"name": "Stripe",
"icon": "simple-icons:stripe",
"iconBackground": "#635BFF",
"iconForeground": "#ffffff",
"engine": {
"capabilities": { "query": ["insertReturning", "updateReturning", "deleteReturning"] },
"entrypoint": "./src/data-connectors/stripe/index.ts",
"permissions": [
{ "permission": "net:api.stripe.com", "reason": "Read and write customers, products and prices through the Stripe API" }
]
}
}
]
}
permissions:net:api.stripe.comis the only host the connector calls.capabilities: the connector answers every write with the items it wrote. See Declare What Writes Return.
See Extension Config and Manifest for every field.
The project's tsconfig.json turns on noUncheckedIndexedAccess and exactOptionalPropertyTypes, which the kit type-checks under. The connector is split by what changes together:
| File | Job |
|---|---|
index.ts | Parses configuration, builds the client, and dispatches each operation |
config.ts | Validates the configuration and applies defaults |
client.ts | Every Stripe request: deadline, paging, and error mapping |
collections.ts | The declared collections and their relation |
resources.ts | The Stripe mapping: paths, form keys, list parameters |
plan.ts | Turns a filter into Stripe requests, or refuses it |
filter.ts | Evaluates a filter in memory, and parses timestamps |
items.ts | Converts between Stripe objects and items |
read.ts | readMany, readOne, keyed lookups, and the fetch budget |
write.ts | Every write operation |
concurrency.ts | Runs a fan-out with at most four requests in flight |
A new collection touches its declaration in collections.ts and its mapping in resources.ts. Keeping planning apart from execution lets you test which requests a filter produces without calling Stripe.
Build the project and install it into your instance's install location:
npm run build -- --install-to ../monospace/extensions
pnpm build --install-to ../monospace/extensions
yarn build --install-to ../monospace/extensions
bun run build --install-to ../monospace/extensions
example/stripe-connector example/stripe-connector.js
Artifact …/stripe-connector/dist
Installed …/monospace/extensions/example__stripe
Restart the engine to load it, unless it runs with MONOSPACE_EXTENSIONS__AUTO_RELOAD=true.
Done when the build installs as example__stripe/ and the instance lists example/stripe-connector in GET /api/{workspace}/connectors/data. An extension can load and still contribute no connector, so check this list, not only the log. See Install and Manage Extensions.
3. Parse Configuration and Build the Client
A data source's configuration reaches setup as unchecked JSON. The Stripe connector reads three keys:
| Key | Type | Default | Meaning |
|---|---|---|---|
apiKey | string | (required) | A secret (sk_) or restricted (rk_) key, test or live mode |
enableWrites | boolean | true for a test-mode key, false otherwise | Declares and allows writes |
maxItemsPerQuery | positive integer | 5000 | The most Stripe objects one read may fetch before the connector refuses it. See Refuse What You Can't Answer |
parseConfig in config.ts returns typed configuration or throws InvalidConfiguration:
- A malformed key fails before any request. Anything not shaped like a Stripe secret or restricted key is refused, such as a publishable key.
- A wrong key passes
setup. Stripe rejects a well-formed key it doesn't recognize with a401, on the connection test or the first query. - Writes default to off for live keys. A live key gets a read-only data source unless its configuration turns writes on.
index.ts builds the client once in setup and dispatches each operation to its handler. setup makes no requests, so it stays fast and can't fail on a network error. testConnection lists one customer, so a key Stripe rejects fails the connection test. There's no preflight, because the only host is fixed and declared in the extension config.
Map Every Stripe Failure
The client owns every Stripe request. Its one fetch call pins the API version and sets a 10-second deadline, because Engine sets none (see Per-Request Deadline). It keeps a rejected request as the cause of its ConnectionFailed, so a request the data source's permissions refused reaches the caller as a permission error.
toConnectorError classifies every error response. It reads Stripe's error body by its code and param, after special cases for rejected keys and rate limits:
function toConnectorError(response: StripeResponse, method: Method, path: string): Error {
const detail = readError(response.body);
const { status } = response;
// The path without the query string, which can hold the caller's filter values.
const message = detail.message ?? `Stripe answered HTTP ${status} to ${method} ${new URL(path, BASE_URL).pathname}.`;
// Only someone with access to the Stripe account can fix the key. Stripe's message for a rejected
// key echoes part of the key, so it stays out.
if (status === 401 || status === 403) return new AccessDenied(`Stripe rejected the API key (HTTP ${status}).`);
if (status === 429) return new RateLimited(`Stripe rate limited the connector.${retryAfter(response.headers)}`);
switch (detail.code) {
case 'resource_missing':
// `param` names what is missing: `id` for the object in the path. On a write, any other
// field is an object the written values refer to, such as a new price's `product`.
if (method === 'POST' && detail.param !== undefined && detail.param !== 'id') return new ForeignKeyConstraintViolation(message);
return new StripeObjectMissing(message);
case 'resource_already_exists':
return new UniqueConstraintViolation(message);
case 'parameter_missing':
return new NullConstraintViolation(message);
}
// Stripe refuses to delete a product that still has prices with no error code, only a message.
if (method === 'DELETE' && detail.type === 'invalid_request_error' && /\bprices\b/u.test(message)) {
return new ForeignKeyConstraintViolation(message);
}
// Treat a server error as an outage: a later request may work.
if (status >= 500) return new ConnectionFailed(message);
// Stripe answers 400 to values it refuses, such as an invalid email: the caller can change them.
if (status === 400 && detail.type === 'invalid_request_error') return new QueryRejected(message);
return new Error(message);
}
- A rate limit is
RateLimited. Stripe answers too many requests per second with a429. The message adds "Retry after 2 seconds." whenRetry-Afteris a whole number of seconds. - A plain
Erroris a failure the caller can't fix, such as a changed API. Engine reports it as a failed query.StripeObjectMissingis one, whichretrieveand the keyed writes answer as a miss instead. - Only the error's code and message reach Engine. So no message carries the key, the query string, or the account's
request_log_url. Stripe's ownmessagepasses through, and it can quote a value the caller sent.
See Handle Data Connector Errors for choosing error classes and Data Connector Errors for what each one does.
Test the connection with the permissions included. The SDK has no method for data sources, so call the endpoint directly:
curl -X POST https://example.monospace.io/api/blog/sources/data/test \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "example/stripe-connector",
"config": { "apiKey": "sk_test_YOUR_STRIPE_KEY" },
"permissions": [
{ "permission": "net:api.stripe.com", "reason": "Read and write customers, products and prices through the Stripe API" }
]
}'
const response = await fetch('https://example.monospace.io/api/blog/sources/data/test', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: 'example/stripe-connector',
config: { apiKey: 'sk_test_YOUR_STRIPE_KEY' },
permissions: [
{ permission: 'net:api.stripe.com', reason: 'Read and write customers, products and prices through the Stripe API' },
],
}),
});
A successful test answers 204 with an empty body. A wrong key fails with 422, and the error nests the connector's message: "Stripe rejected the API key (HTTP 401)."
Done when:
setupthrowsInvalidConfigurationfor{}, for a publishable key such aspk_test_123, and for"maxItemsPerQuery": 0, without making a request;- a connection test without
permissionsanswers422with code7001, then succeeds with the returned set (see Network Permissions); - a connection test with a wrong key fails, and one with your test key succeeds.
4. Declare the Schema and Create a Data Source
Stripe has no metadata API, so the connector declares its collections statically, pinned to one Stripe API version. The filters come straight from the capability table:
/**
* Stripe has no metadata API, so its objects are declared here and pinned to one API version:
* `STRIPE_API_VERSION` and the field paths in `resources.ts` change together.
*/
export const STRIPE_API_VERSION = '2026-08-26.dahlia';
// The filters below declare only what Stripe answers exactly, and `plan.ts` sends a filter to
// Stripe only when it's declared here: `id` by retrieving it, `created` through `created[gte]` and
// `created[lte]`, and every other field through its list parameter in `resources.ts`.
const exact = filter.field('equals', 'in');
/** Engine executes only `equals` on booleans. */
const booleanExact = filter.field('equals');
const createdRange = filter.field('equals', 'lessThan', 'lessThanOrEquals', 'greaterThan', 'greaterThanOrEquals');
const andOr = filter.logical('and', 'or');
/** Stripe lists newest first and can't sort any other way. */
const newestFirst = { created: sort.field('descending') };
// Values Stripe sets itself: callers never have to supply them on create.
const setByStripe = { defaultValue: defaultValue.connectorManaged() };
const id = field.string(setByStripe);
const created = field.dateTimeWithTimezone(setByStripe);
const nullableText = field.string({ nullable: true });
// A field is writable exactly when a `data` list below names it: the kit derives the read-only
// flags from these lists. `balance` and `unitAmount` are 64-bit integers, which travel as strings.
const customerFields = {
id,
name: nullableText,
email: nullableText,
phone: nullableText,
description: nullableText,
balance: field.int64(setByStripe),
created,
};
const customerFilter = { fields: { id: exact, email: exact, created: createdRange }, logicalOperators: andOr };
const customerData = fieldsOf(customerFields).pick('name', 'email', 'phone', 'description', 'balance');
export const customers = defineCollection({
name: 'customers',
fields: customerFields,
indexes: { customers_pkey: index.primary(['id']) },
operations: {
readMany: { filter: customerFilter, sort: newestFirst, limit: paging.limit(), offset: paging.offset(), output: fields.all() },
readOne: { filter: customerFilter, output: fields.all() },
create: { data: customerData, output: fields.all() },
updateMany: { filter: customerFilter, data: customerData, output: fields.all() },
updateOne: { filter: customerFilter, data: customerData, output: fields.all() },
deleteMany: { filter: customerFilter, output: fields.all() },
deleteOne: { filter: customerFilter, output: fields.all() },
},
});
- Sort is
createddescending only, the one order Stripe lists in. No collection declaresmeta.totalCount, because Stripe lists have no total count. - One read filter is reused by every member. Engine requires write filters to stay within
readMany.filter. - Values Stripe sets carry a
connectorManageddefault, so callers never have to supplyid,createdorbalanceon create. productsandpricesfollow the same pattern. A product's create also takes a caller-chosenid, andpricesdeclares no deletes, because Stripe can't delete prices.
schemaDocument(enableWrites) passes writes: enableWrites to the kit's toSchemaDocument. With writes off, every writing operation is dropped, and Engine refuses a write before it reaches the connector.
defineSchema also declares the prices_product relation. Engine resolves each direction of a relation by filtering the other collection by its key field. Both products.id and prices.productId declare in, so both directions work. See Resolve Relations.
Map Fields to Stripe
The query code also needs each field's place in a Stripe object, its form key for writes, and its list parameter. resources.ts keeps that mapping, keyed by the declared field names, so tsc reports a typo:
/** Where a field lives in Stripe, when that differs from its snake_case name. */
interface FieldMapping {
/** Location in the Stripe object. Defaults to the field's name in snake_case. */
readonly path?: readonly string[];
/** Form key for writes. Defaults to the field's name in snake_case. */
readonly formKey?: string;
/** The list parameter that filters the field by exact value. */
readonly listParameter?: string;
/**
* Values the list parameter accepts. Stripe rejects an unknown enum value with an error, where a
* filter on one matches no item, so the plan never sends other values.
*/
readonly listValues?: RegExp;
}
// …
const BOOLEAN_VALUES = /^(?:true|false)$/u;
const RESOURCES: readonly ResourceSpec[] = [
toSpec(customers, 'customers', {
email: { listParameter: 'email' },
}),
toSpec(products, 'products', {
active: { listParameter: 'active', listValues: BOOLEAN_VALUES },
}),
toSpec(prices, 'prices', {
productId: { path: ['product'], formKey: 'product', listParameter: 'product' },
active: { listParameter: 'active', listValues: BOOLEAN_VALUES },
type: { listParameter: 'type', listValues: /^(?:one_time|recurring)$/u },
recurringInterval: { path: ['recurring', 'interval'], formKey: 'recurring[interval]' },
}),
];
toSpec joins each declaration with its mapping, so the plan and the writes never disagree with the declaration. items.ts converts values in both directions:
- Timestamps: Stripe's Unix seconds become RFC 3339 strings.
- Missing or unexpected values fail with a plain
Error, unless the field is nullable and the value is missing. - Written
null: an update sends an empty string, which unsets the field in Stripe. A create omits the field. - 64-bit integers: Engine sends
int64values, such asbalanceandunitAmount, as decimal strings. Callers read both fields as strings (e.g.,"balance": "0").
typeof value === 'number' on an int64 field or key. Engine sends a string, so such a check misses every value.Create a data source now, before you write query code. Engine refuses a declaration that breaks its rules at create time, which is cheaper to fix before planning code depends on it:
curl -X POST https://example.monospace.io/api/blog/sources/data \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiName": "stripe",
"provider": "example/stripe-connector",
"config": { "apiKey": "sk_test_YOUR_STRIPE_KEY" },
"permissions": [
{ "permission": "net:api.stripe.com", "reason": "Read and write customers, products and prices through the Stripe API" }
]
}'
const response = await fetch('https://example.monospace.io/api/blog/sources/data', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
apiName: 'stripe',
provider: 'example/stripe-connector',
config: { apiKey: 'sk_test_YOUR_STRIPE_KEY' },
permissions: [
{ permission: 'net:api.stripe.com', reason: 'Read and write customers, products and prices through the Stripe API' },
],
}),
});
In Studio, enter the same configuration in the data source's JSON configuration editor. Add "enableWrites": false for a read-only data source.
Done when the create answers 200 with the data source's id, and prices offers every operation except deleteMany and deleteOne.
The data source keeps this schema. When you change a declaration later, rebuild and refresh the data source's schema; see Update Data Sources After an Extension Update.
5. Implement Reads
Stripe has a weak grammar: a few list parameters with fixed meanings, and no filter language. The connector plans each filter into a union of exact Stripe requests, or refuses it before sending anything.
Plan the Filter
planRequests turns a filter into list requests with parameters, or retrieves by id. It makes no requests itself:
/**
* One request to Stripe. A `list` request returns exactly the items of one filter branch, in
* Stripe's newest-first order. A `retrieve` request fetches objects by id; the caller checks the
* rest of the filter on them in memory, which stays cheap because the ids bound the work.
*/
export type StripeRequest =
| { readonly kind: 'list'; readonly parameters: Readonly<Record<string, string>> }
| { readonly kind: 'retrieve'; readonly ids: readonly string[] };
// …
/**
* Turns a filter into Stripe requests whose union is exactly the matching items. A filter Stripe
* can't express is refused rather than answered by reading every item, unless top-level id
* constraints bound the candidates.
*/
export function planRequests(resource: ResourceSpec, filter: FilterSerialized | undefined): StripeRequest[] {
requireDeclared(resource, filter);
const ids = topLevelIds(filter);
if (ids !== undefined) {
// The retrieved objects are re-checked against the whole filter in memory. Refuse a filter that
// check can't evaluate now, before any request.
requireEvaluable(resource, filter);
return ids.length === 0 ? [] : [{ kind: 'retrieve', ids }];
}
let branches: Branch[];
try {
branches = filter === undefined ? [EVERYTHING] : toBranches(resource, filter);
}
catch (error) {
if (!(error instanceof NotExpressible)) throw error;
throw new QueryRejected(`Stripe can't filter \`${resource.name}\` by ${error.message} without reading every item.`);
}
// …
if (requests.size > MAX_REQUESTS) throw tooManyRequests(resource);
return [...requests.values()];
}
toBrancheswalks the filter tree.andintersects its children's constraints, andorandinfan out into one branch per value. A negation, a field compared with a field, orfield = nullcan't be expressed, so the plan refuses it.requireDeclaredruns first. Engine sends filters to the connector without checking them against the declaration. So the connector refuses an undeclared field or operator itself, with a plainError.- Values outside an enum match nothing. The plan never sends
type=bogus, which Stripe would reject. It sends no request for that value instead. - Fan-out is bounded. A filter that plans more than 250 requests is refused with
QueryRejected.
Convert Timestamps Exactly
Stripe stores created in whole seconds, and Engine sends RFC 3339 timestamps that can carry a fraction of a second. parseInstant in filter.ts parses them strictly and keeps every fractional digit. Date.parse alone would accept 2026, drop digits below the millisecond, and roll 2026-02-30 over to March 2nd.
createdBranch turns each comparison into an exact inclusive range of whole seconds:
/**
* Stripe stores `created` in whole seconds, so each comparison becomes an exact inclusive range.
* A timestamp with a fraction lies between two whole seconds: `created >= 10.5` is `created >= 11`.
*/
function createdBranch(operator: FilterOperator, value: Value): Branch {
const { seconds, nanoseconds } = parseInstant(value);
const hasFraction = nanoseconds > 0;
switch (operator) {
// A whole second never equals a timestamp with a fraction: lower > upper, so no request is sent.
case 'equals': return { ...EVERYTHING, lower: hasFraction ? seconds + 1 : seconds, upper: seconds };
case 'greaterThan': return { ...EVERYTHING, lower: seconds + 1 };
case 'greaterThanOrEquals': return { ...EVERYTHING, lower: hasFraction ? seconds + 1 : seconds };
case 'lessThan': return { ...EVERYTHING, upper: hasFraction ? seconds : seconds - 1 };
case 'lessThanOrEquals': return { ...EVERYTHING, upper: seconds };
default: throw new NotExpressible(`\`created\` with \`${operator}\``);
}
}
Filter on created | t is a whole second | t has a fraction |
|---|---|---|
> t | created[gte]=t+1 | created[gte]=⌊t⌋+1 |
>= t | created[gte]=t | created[gte]=⌊t⌋+1 |
< t | created[lte]=t-1 | created[lte]=⌊t⌋ |
<= t | created[lte]=t | created[lte]=⌊t⌋ |
= t | created[gte]=t&created[lte]=t | No request: no item can match |
A value that isn't a strict RFC 3339 timestamp is refused with QueryRejected before any request.
Page and Fill the Limit
Stripe caps a page at 100, and Engine reads a page shorter than limit as the end of the data. So the client keeps paging with starting_after until it has what the caller asked for:
async* list(path: string, parameters: Readonly<Record<string, string>>, offset = 0, wanted = Infinity): AsyncGenerator<StripeObject> {
let startingAfter: string | undefined;
let remaining = wanted;
for (;;) {
const query = new URLSearchParams({ ...parameters, limit: String(Math.min(PAGE_SIZE, remaining)) });
if (startingAfter !== undefined) query.set('starting_after', startingAfter);
else if (offset > 0) query.set('offset', String(offset));
const response = await this.#send('GET', `${path}?${query}`);
// Stripe answers a filter on a reference that doesn't exist, such as `prices?product=` with a
// missing product, with `resource_missing` naming the parameter. No object can match it.
if (startingAfter === undefined && isMissingReference(response, parameters)) return;
const page = parseList(checked(response, 'GET', path));
remaining -= page.data.length;
yield* page.data;
const last = page.data.at(-1);
if (!page.hasMore || last === undefined || last.id === startingAfter || remaining <= 0) return;
startingAfter = last.id;
}
}
- A missing reference is no match. A
productIdfilter naming a product that doesn't exist returns no prices, not an error. - The loop can't spin. It stops when Stripe has no more, the page is empty, the cursor doesn't advance, or it has what the caller wants.
With several requests, findItems in read.ts reads each list to offset + limit, merges the results newest first, and slices. Stripe doesn't document its order within one second. So each list also reads every object created in the same second as its last one, and the merge breaks ties by id. Consecutive pages then never overlap or skip an item.
Re-Check Every Item the Plan Doesn't Prove
A single list request is exact by construction, so it needs no re-check. Retrieved ids and merged fan-outs pass through matches before they're sliced. It uses SQL three-valued logic and compares timestamps as nanoseconds. See Re-Check Every Item.
Refuse What You Can't Answer
Refuse a request you can't answer correctly, before any request to Stripe when you can. Never return a silently shortened page. Pick the class by whose request it is:
QueryRejectedfor a valid request Stripe can't serve as asked, such as a negation without ids. Engine answers422, and the message tells the caller what to change.- A plain
Errorfor a request outside the declaration, such as another sort. Engine refuses those from callers, so they come from Engine itself or a schema not refreshed after an extension update. Engine answers500.
The fetch budget covers reads Engine leaves unbounded, such as a caller's limit=-1 or a relation read (see Relation Reads). FetchBudget in read.ts counts every object a read fetches. Past maxItemsPerQuery, it throws QueryRejected. Each readMany, and each bulk write's pre-read, gets its own budget; keyed operations spend none.
The caller gets 422 "The data source rejected the query", with the connector's message nested inside: "Reading customers would fetch more than 5000 Stripe objects. Narrow the filter, request a smaller page, or raise the connector's maxItemsPerQuery."
The budget bounds one read, not a caller's whole query. A to-many include can send one read per parent item, up to 16 at once, each with its own budget. See Handle Requests You Didn't Declare for every shape Engine sends on its own.
Done when each read matches what Stripe returns for the same request. The names below assume the workspace's default camelCase naming; see API Names.
Read one product with its prices across the relation. The SDK tab uses a client generated after the data source exists; see SDK Installation:
import { createClient } from './generated/monospace';
const client = createClient({
url: 'https://example.monospace.io',
workspace: 'blog',
apiKey: 'YOUR_API_KEY',
});
const product = await client.products.readOne({
key: 'prod_YOUR_PRODUCT',
fields: ['id', 'name', 'active'],
include: {
prices: { fields: ['id', 'unitAmount', 'currency', 'recurringInterval'] },
},
});
curl -g "https://example.monospace.io/api/blog/items/products/prod_YOUR_PRODUCT?fields=id,name,active&include[prices][fields]=id,unitAmount,currency,recurringInterval" \
-H "Authorization: Bearer YOUR_API_KEY"
const params = new URLSearchParams({
'fields': 'id,name,active',
'include[prices][fields]': 'id,unitAmount,currency,recurringInterval',
});
const response = await fetch(
`https://example.monospace.io/api/blog/items/products/prod_YOUR_PRODUCT?${params}`,
{
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
);
const { data } = await response.json();
The product arrives with its prices nested under prices.data. unitAmount is an int64 field, so it arrives as a string:
{
"data": {
"id": "prod_msfx_starter",
"name": "Fixture Starter",
"active": true,
"prices": {
"data": [
{
"id": "price_1UIxWu9H5XlP98EJrPjZftI9",
"unitAmount": "900",
"currency": "eur",
"recurringInterval": "month"
}
]
}
}
}
Also check that a limit above 100 returns Stripe's first page plus the next, in order. A created _gte filter one microsecond after a customer's created excludes that customer.
6. Implement Keyed Operations and Writes
readOne, updateOne and deleteOne address one item by its key, plus an optional guard filter. findByKey retrieves the item by id, then checks the guard in memory:
/**
* Retrieves the keyed item and checks the guard on it in memory. A missing object, a deleted one
* and a guard miss all answer `undefined`: a miss, so a keyed write changes nothing.
*/
export async function findByKey(context: QueryContext, resource: ResourceSpec, query: KeyedOperation): Promise<Item | undefined> {
const id = query.key.id;
if (Object.keys(query.key).length !== 1 || id === undefined) {
throw new Error(`\`${resource.name}\` keys are a single \`id\`.`);
}
// The guard is checked in memory after the retrieve. Refuse one that isn't declared, or that the
// check can't evaluate, first.
requireDeclared(resource, query.filter);
requireEvaluable(resource, query.filter);
// Only a string can be a Stripe id. The client also skips ids Stripe can't have.
if (typeof id !== 'string') return undefined;
const object = await context.client.retrieve(resource.path, id);
if (object === undefined) return undefined;
const item = toItem(resource, object);
return matches(resource, query.filter, item) ? item : undefined;
}
- A miss is an answer, not an error. An unknown id, a deleted customer, and a guard miss all answer
undefined. The client'sretrievereads both Stripe'sresource_missingand adeleted: truetombstone as missing. - The answer is
{ record }.readOneanswers{ record: null }on a miss, and{ record: … }with the selected fields otherwise. Engine answers a keyed miss with404. See Keyed Operations. - The guard is checked before any write.
updateOneanddeleteOnewrite only an itemfindByKeyreturned, so a guard miss writes nothing.
Another client can still delete the object between the retrieve and the write. Stripe then answers resource_missing, which the client throws as StripeObjectMissing, and the keyed write answers that as a miss too:
export async function updateOne(context: QueryContext, resource: ResourceSpec, query: UpdateOneOperationSerialized): Promise<QueryResultFor<'updateOne'>> {
requireWrite(context, resource, resource.canUpdate, 'update');
const form = encodeForm(resource, query.data, 'update');
const target = await findByKey(context, resource, query);
if (target === undefined) return { record: null };
try {
const object = await context.client.update(resource.path, target.id, form);
return { record: project(toItem(resource, object), query.select) };
}
catch (error) {
// Deleted since `findByKey` retrieved it: a miss like any other.
if (error instanceof StripeObjectMissing) return { record: null };
throw error;
}
}
deleteOne works the same way. It answers the item findByKey read, because Stripe answers a delete with a tombstone.
Write One Object per Request
Stripe writes one object per request and returns the written object. create checks every item before its first request, then creates them in order. A failure partway leaves the earlier items created, so the message says how far it got:
export async function create(context: QueryContext, resource: ResourceSpec, query: CreateOperationSerialized): Promise<QueryResultFor<'create'>> {
requireWrite(context, resource, resource.canCreate, 'create');
// Check every item before creating any.
const forms = query.data.map((data) => encodeForm(resource, data, 'create'));
const records: MonospaceObject[] = [];
// One at a time, so a failure leaves the items before it created, in order.
for (const form of forms) {
try {
const object = await context.client.create(resource.path, form);
records.push(project(toItem(resource, object), query.select));
}
catch (error) {
throw withProgress(error, `Created ${records.length} of ${forms.length} ${resource.name}`, forms.length);
}
}
return { records };
}
- Writes by filter read their targets first.
updateManyanddeleteManyread every match, then write each one, with at most four requests in flight. That bounds concurrency, not the rate, so Stripe can still answer429. - Writes aren't atomic. When a write of several items fails,
withProgressprefixes the message with the count of acknowledged writes and keeps the error's class. A request that timed out can still have been applied, so the real count can be higher. deleteManyanswers the items it read before deleting, because Stripe answers each delete with a tombstone.
Deleting four products by filter, where one still has prices, deletes the other three and reports the failure:
{
"message": "Query failed",
"source": {
"message": "Foreign key constraint failed",
"source": {
"message": "the extension returned an error: Deleted 3 of 4 products before this error: This product cannot be deleted because it has one or more user-created prices."
}
}
}
Every write passes the same gate before its first request. With enableWrites: false, a write is a QueryRejected, because it can still arrive while Engine holds a schema introspected before the setting changed. A write or field the collection doesn't declare is a plain Error.
Creating a price sets its product through the relation. Engine doesn't accept the foreign-key field productId in create input, so connect the existing product with _connect. The SDK tab reuses the client from step 5:
const prices = await client.prices.createOne({
data: {
currency: 'usd',
unitAmount: 1500,
recurringInterval: 'month',
product: { _connect: { key: { id: 'prod_YOUR_PRODUCT' } } },
},
fields: ['id', 'productId', 'unitAmount', 'currency'],
});
curl -X POST "https://example.monospace.io/api/blog/items/prices?fields=id,productId,unitAmount,currency" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"currency": "usd",
"unitAmount": 1500,
"recurringInterval": "month",
"product": { "_connect": { "key": { "id": "prod_YOUR_PRODUCT" } } }
}'
const params = new URLSearchParams({ fields: 'id,productId,unitAmount,currency' });
const response = await fetch(
`https://example.monospace.io/api/blog/items/prices?${params}`,
{
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify([{
currency: 'usd',
unitAmount: 1500,
recurringInterval: 'month',
product: { _connect: { key: { id: 'prod_YOUR_PRODUCT' } } },
}]),
},
);
const { data } = await response.json();
Engine doesn't check that the product exists. Stripe answers a missing product with resource_missing naming product, and the caller gets 422 "Foreign key constraint failed". Stripe can't delete prices: archive a price by updating active to false.
Declare What Writes Return
Every write handler answers the items it wrote, with exactly the fields the call's select asks for. The extension config tells Engine so in engine.capabilities.query, one value per kind of write:
| Capability | Promise | How the Stripe connector keeps it |
|---|---|---|
insertReturning | create answers the created items | Stripe answers each create with the new object |
updateReturning | updateMany and updateOne answer the updated items | Stripe answers each update with the updated object |
deleteReturning | deleteMany and deleteOne answer the deleted items | The handlers answer the item they read before deleting it |
A write that fails partway throws, and never answers with a partial list, so the promise holds for every answer.
Without the capabilities, Engine gets the written items from extra calls instead. A keyed update then costs five Stripe requests instead of two, and another writer can change an item between the calls. Each missing capability also adds declaration rules, which Engine checks when you create the data source. See Declare Query Capabilities.
With automatic reloading on, a build whose capabilities differ from the ones a data source was loaded with deactivates that data source. Every request that reaches its connector answers 503 until a restart or a workspace rebuild. See Capabilities.
Done when:
- a customer create, update and delete each return the selected fields, and a read after the delete answers
404; - an update with a guard that misses answers
404, and a read shows nothing changed; - deleting a product that has a price fails with
422"Foreign key constraint failed", with nodashboard.stripe.comlink in the error; - a data source created with
"enableWrites": falserefuses a create with422and code4010; - the built
dist/manifest.jsonlists all three capabilities underengine.capabilities.query.
7. Test the Connector
Test in rungs, from cheapest to most realistic. The project's package.json has a script for each rung except the install:
| Script | What it runs | What it asserts |
|---|---|---|
lint | ESLint with typescript-eslint's strict type-checked rules | No lint errors |
typecheck | tsc --noEmit | No type errors, including typos in field names and mapping keys |
test | Vitest, against a fake Stripe, with no network | 110 tests: planning, the re-check, error mapping, timeouts, keyed operations, reads, and writes |
build | monospace extension build | A bundle and a manifest |
seed | scripts/seed.mjs against your test-mode account | Creates the fixture customers, products and prices that are missing |
smoke | scripts/smoke.mjs, which imports the built connector and calls Stripe | 19 named checks, including results compared with Stripe's own answers |
smoke:engine | scripts/engine-smoke.sh against a running instance | 47 checks of status codes and response bodies through the REST API |
lint, typecheck, test and build need no Stripe key. The unit tests replace fetch with a fake Stripe. So they cover what a live account can't produce on demand, such as a timeout, a 429, or a bulk write that fails partway.
The live scripts take the key only from the environment, and the project stores none:
| Variable | Used by | Meaning |
|---|---|---|
STRIPE_API_KEY, or STRIPE_API_KEY_FILE | seed, smoke, smoke:engine | A test-mode key, or a file that holds only the key. The scripts refuse any other key |
ENGINE_URL | smoke:engine | The instance's base URL, http://localhost:8100 by default |
ENGINE_EMAIL, ENGINE_PASSWORD | smoke:engine | An admin login, monospace@example.com and monospace by default |
smoke:engine also needs curl and jq. Each run leaves one archived price on prod_msfx_locked, because Stripe can't delete prices.
Then break the connector on purpose and confirm a check fails:
- A
schemaDocumentthat ignoresenableWritesstill type-checks. It fails two unit tests and the Engine smoke. - Keyed writes that don't catch
StripeObjectMissingfail the unit test "answersrecord: nullwhen the object is deleted between the retrieve and a keyed write", and the smoke. - An extension config without
capabilitiesstill builds, and Engine accepts the declaration. It fails the smoke's manifest check.
Done when every rung passes twice in a row against the test-mode account, and each deliberate break turns a specific check red. See Test a Data Connector for the rungs, fixtures and techniques.
Next Steps
- Map an External Data Source: rich and weak grammars, paging, keys and writes on other external data sources
- Handle Data Connector Errors: map each failure from the external data source to the right error class
- Test a Data Connector: the test rungs and fixtures in detail
- Operations and Operators: the declaration rules Engine enforces
- Data Connector Limits: deadlines, page sizes and concurrency