OData datasources — ERP records, live, with no driver of your own
Declare a datasource over an OData v4 service such as SAP S/4HANA or Dynamics 365, bind objects to its entity sets, and read and write them live, with no code per entity set.
Many business systems already publish their records over OData v4: SAP S/4HANA,
Microsoft Dynamics 365 and Dataverse, and any service that serves a CSDL 4.0
$metadata document. For those you need no driver of your own. Mount
@objectstack/driver-odata once per service, declare a datasource, and bind an object
to each entity set you want. Every view, form, permission set, formula and hook then
works on those records, and no row is stored on the platform side.
The OData driver is a plugin driver, so everything Virtual objects says about plugin drivers holds here too: the engine re-applies every query, a read matches at most 1000 rows, and the sharing model needs a deliberate choice. The one difference is that this driver is already written. Which way in says when to choose it over a driver of your own or a synced copy.
Everything on this page is what the driver's own tests assert, against an in-process
OData service and behind the real engine
(packages/drivers/driver-odata/src/odata-driver.floor.test.ts and
odata-driver.conformance.test.ts). Read them when this page and the runtime seem to
disagree.
Check the service first
- It speaks OData v4. Open
<serviceRoot>$metadata. The root element must sayVersion="4.0". Only the v4 dialect ships. A service that speaks only OData v2 needs a v2 dialect, which this package does not include. Many older SAP Gateway services are v2 only, so check each service you plan to bind. - Each entity set you bind has a key. The key becomes the record's
id. An entity type with no usable key is refused at the first request. - You know how it authenticates. The driver sends no credential, a bearer token, or HTTP basic. It does not run an OAuth2 flow (Credentials).
1. Mount the plugin
The plugin builds the driver from its options and publishes it as the service
driver.<name>. Mount it in the stack's plugins, one plugin per service:
import { defineStack } from '@objectstack/spec';
import { createODataDriverPlugin } from '@objectstack/driver-odata';
export default defineStack({
// …the datasource and objects of step 2…
plugins: [
createODataDriverPlugin({
name: 'erp', // = the datasource name
serviceRoot: 'https://erp.example.com/odata/v4/sales/',
auth: { scheme: 'bearer' }, // under os serve, no `secrets`: see Credentials
}),
],
});| Option | Default | Meaning |
|---|---|---|
name | odata | The datasource it serves. It is also the driver name and the driver.<name> service suffix. |
serviceRoot | (required) | The service root URL, http or https, with no credentials and no query string. |
version | 4.0 | The protocol version. 4.0 is the only value. |
auth | { scheme: 'none' } | none, bearer, or basic with a username. The secret is the datasource's external.credentialsRef. |
secrets | (none) | Where credentialsRef is resolved: an object with resolve(credentialsRef). When absent, the host's datasource-secret-resolver service is used; os serve registers one. |
pageSize | 500 | Rows asked for per server page (Prefer: odata.maxpagesize). |
requestTimeoutMs | 30000 | The timeout of each request. |
The options are checked when the plugin is built. An unknown key, a serviceRoot with
a user name or a query in it, or a version other than 4.0 refuses the boot with every
problem named.
2. Declare the datasource and bind objects
import { defineStack } from '@objectstack/spec';
export default defineStack({
manifest: { id: 'com.example.sales', name: 'Sales', version: '1.0.0', type: 'app' },
datasources: [
{
name: 'erp',
label: 'ERP sales service (OData v4)',
driver: 'erp', // the plugin's name, i.e. the `driver.erp` service
schemaMode: 'external',
config: {}, // the plugin's options configure the driver, not this
external: {
allowWrites: true,
credentialsRef: 'sys_secret:7c1e…', // the bearer token, as a secret-store handle
validation: { onMismatch: 'warn', checkOnBoot: false },
},
},
],
objects: [
{
name: 'sales_order',
label: 'Sales order',
datasource: 'erp',
sharingModel: 'public_read_write', // see "Choose the sharing model" below
external: {
remoteName: 'SalesOrders', // the entity set
writable: true,
// remote property → local field; a property not named keeps its own name
columnMap: {
SalesOrderID: 'order_number',
CustomerName: 'customer_name',
NetAmount: 'net_amount',
OverallStatus: 'status',
DeliveryNote: 'delivery_note',
},
},
fields: {
order_number: { type: 'text', label: 'Order' },
customer_name: { type: 'text', label: 'Customer' },
net_amount: { type: 'number', label: 'Net amount' },
status: { type: 'text', label: 'Status' },
delivery_note: { type: 'text', label: 'Delivery note' },
},
},
],
});What each piece does on this driver:
- Three names stay equal: the plugin's
name, the datasourcename, and the datasourcedriver. The connect step findsdriver.erpalready registered and uses it as it is.configis left empty, because the plugin's options already configured the driver. remoteNamenames the entity set. Without it the driver looks for an entity set named like the object.- This driver applies
external.columnMapitself, in both directions: rows arrive under your field names, and filters, sorts and writes go out under the service's property names. That is a property of this driver, not of plugin drivers in general. idis the entity key. A single-property key is read as text ('4711'). A composite key is the body of its key predicate ('OrderID=1,ProductID=7'), which is also whatGET /data/<object>/<id>takes.- Or import the objects from
$metadata. Once the datasource is saved, Studio's datasource page lists its entity sets on the Tables tab. Import saves one, and Import selected saves several, referenced entity sets first. An MCP client whose token carriesmetadata:writereaches the same import throughlist_remote_tablesandimport_object. The draft maps each EDM type to a field type, makes the key theid, and writescolumnMapfor every property whose name is not a snake_case field name (CategoryIDbecomescategory_id). EDM types with no field type, such asEdm.Durationor a service's own enum, are listed for review. An imported object is read-only unless the datasource allows writes and you tick Allow writes to this object. - Navigation properties are never followed, so a related entity set is an object of
its own. The import turns a single-valued navigation into a
lookupon its foreign-key property when itsReferentialConstraintis one property pair onto the target's key and the target is already imported on the same datasource. Every other navigation is left out and named in the review, so import the target first. The lookup reads the related record by key, like any other lookup. These rules are whatpackages/services/service-datasource/src/__tests__/external-object-draft-odata-metadata.test.tsasserts, over a recorded Northwind$metadata. - The service document is read lazily.
connect()asks the service nothing, and$metadatais read once, at the first request. A wrongserviceRootor an unknown entity set shows up on the first read of the object, not at boot. checkOnBoot: falsekeeps the boot from depending on the service being up. The driver's tests do not cover the boot-time drift check over an OData service, so test it against your service before you turn it on.
3. Credentials
⛔ Never put a credential in the stack, in the plugin options, or in the serviceRoot
URL. The secret is the datasource's external.credentialsRef, and the plugin resolves it
through the host's secret store:
bearersendsAuthorization: Bearer <secret>.basictakesusernamefrom the options and the password from the secret.nonesends nothing. AcredentialsRefdeclared besidenoneis refused, so a credential you meant to send is never left out by mistake.
The plugin resolves the secret at the first request, not at boot, and again after a
401. So a token rotated in the secret store is picked up by the next request that the
old token fails. The driver does not obtain or refresh tokens itself. A service that
issues short-lived OAuth2 access tokens needs something that keeps the stored secret
current.
Which store resolves it:
- Under
os serve, pass nosecrets. The server publishes its own datasource secret store as the kernel servicedatasource-secret-resolver, and the plugin reads it at the first request. Asys_secret:<id>handle that the Add Datasource wizard minted resolves, as on any federated datasource (Credentials). - A host that composes its own kernel passes
secrets: the same binder it givesDatasourceAdminServicePlugin, or any object withresolve(credentialsRef)answering the secret orundefined. It can instead register thatresolveas thedatasource-secret-resolverservice (DATASOURCE_SECRET_RESOLVER_SERVICEin@objectstack/types), which every plugin driver reading acredentialsRefuses the same way. Asecretsoption passed to the plugin always wins over the service.
Resolution fails closed. Each of these refuses the request rather than send it without
the credential: an auth scheme with no credentialsRef, a credentialsRef with neither
secrets nor the service, and a handle that does not resolve. The caller is answered
503 ERR_DATASOURCE_UNAVAILABLE, and the cause goes to the server log.
4. What the service is asked
Behind the floor, a list read is one GET on the entity set:
GET <serviceRoot>SalesOrders?$filter=OverallStatus eq 'open'&$orderby=NetAmount desc,SalesOrderID&$top=1001
Prefer: odata.maxpagesize=500$filtercarries every part of thewherethe driver can translate:$eq,$ne,$gt,$gte,$lt,$lte,$between,$in,$nin,$null,$contains,$startsWith,$endsWithand$icontains(ASCII comparands), under$andand$or.- The rest is left to the engine, which re-applies the whole query to the rows the
service returns:
$not,$like,$exists, field references, and ordering or negation on text. The driver only ever asks a wider question. It drops a member of an$andit cannot translate, and drops a whole$orwhen one branch cannot be translated, so the service never returns fewer rows than match. This is also why a service that compares text case-insensitively still answers correctly. $orderbycarries the sort, then the entity key, so pages partition the result.- Paging follows
@odata.nextLinkuntil 1001 rows are read. A link to another origin is refused, so the credential never goes to a host you did not configure. - The page size and the total are the engine's, applied to the rows read. A count is computed the same way. The engine aggregates in memory.
The 1000-row cap applies. A read that matches more than 1000 rows answers
422 EXTERNAL_ROW_CAP_EXCEEDED rather than a partial set
(the cap). Every
$filter the driver can push down makes the service return only matching rows, so the cap
counts matching rows, not the whole entity set. Give list views a filter the service
can run. For an entity set users must browse whole, mirror it into a real object
instead.
5. Writes
Writes are the same double opt-in as federation: external.allowWrites on the
datasource and external.writable on the object
(Writes).
| The platform does | The service is sent |
|---|---|
POST /data/sales_order | POST SalesOrders |
PATCH /data/sales_order/:id | PATCH SalesOrders(<key>) |
DELETE /data/sales_order/:id | DELETE SalesOrders(<key>) |
If-Matchcarries the entity's@odata.etagwhen the driver's last read of that record returned one, so a write over someone else's change is refused by the service.- Only properties the entity type declares are sent. The fields the engine stamps
itself (
updated_at,updated_by) are not the service's and are left out. - The service's refusal reaches the caller. A
400,409,412or422from the service answers422 EXTERNAL_WRITE_REFUSEDwith the service's own message. Let the ERP's validation be the rule, and do not retry around it. - Refused by name:
upsert, the bulk writes, and transactions. Without$batchthere is no atomic multi-row write. A write that selects its rows by a filter is refused too. Read the ids, then write each one.
Choose the sharing model
A record from an OData service has no owner_id and no created_by, so the default
sharing model admits no row for a member, and an owner-keyed write is refused. The rule
and the ways out are the same as on any plugin driver
(Choose the sharing model on purpose):
- Read-only data: leave
allowWritesoff and declaresharingModel: 'public_read'. Who may read is the permission sets' decision. - Editable data: declare
sharingModel: 'public_read_write', and decide who may edit in abeforeUpdatehook. A member's delete is still refused on this model.
When the service fails
| What happened | The caller is answered |
|---|---|
A read the service refuses for its own sake (400, 404, 422) | 400 INVALID_QUERY, with a message naming the entity set, the status and the service's own text |
| A write the service refuses | 422 EXTERNAL_WRITE_REFUSED, with the service's message |
A 5xx, a timeout, an unreachable service, a 401 after the retry, a 403 or a 429 | 503 ERR_DATASOURCE_UNAVAILABLE; the cause goes to the server log |
| A read that matches more than 1000 rows | 422 EXTERNAL_ROW_CAP_EXCEEDED |
An outage is never answered as an empty list. A read refusal's message is withheld, and goes to the server log, when the read carries a predicate the caller did not write, such as a sharing scope. The full mapping is in Virtual objects.
What it does not do
- OData v2. Only the v4 dialect ships.
$expandand navigation properties. Bind each related entity set as an object of its own; an imported foreign key becomes alookuponto it.- Actions and functions of the service.
executeis refused. - Transactions and
$batch. Each write is one request. - More than 1000 matching rows in one read (the cap above).
- A read scoped by a parent's access (an object
controlled_by_parent): it is refused, because$filterhas no form of that join.
See also
- Virtual objects: which way in, the engine's floor, the sharing-model callout, and writing a driver for a system with no OData.
- External datasources: the datasource and
externalbinding, federation over SQL, the write gates and credentials. - Pulling a mapping: a real object fed from a remote API on a schedule.
packages/drivers/driver-odata/README.md: the package and its exports.
External Datasources (Federation)
Declare an external database as a datasource and query its tables as ObjectStack objects — visible, auto-connected, and queryable with zero app code.
Database Drivers
Connect ObjectStack to PostgreSQL, MySQL, SQLite, MongoDB, Turso or memory — URL-based driver inference, per-driver config keys and dialect caveats.