Skip to content

Commit 6e53003

Browse files
docs(data-modeling): an OData datasources guide, and the three-way choice between declarative OData, a plugin driver and a synced real object (#27871)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiKpjJFk37ZN4qtHqsLTEJcU)_ Fixes #27747 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 新增指南页「OData datasources」:用 OData v4 声明式接入 SAP S/4HANA、Dynamics 365 的完整示例(挂插件、声明数据源、绑定实体集、`credentialsRef` 认证、下推与兜底、写入门槛、失败时调用方看到什么)。「Virtual objects」页开头改为三选一规则:慢变参考数据用真表加定时同步,有 OData v4 用声明式 OData,没有 OData、按需计算或私有 API 才写插件驱动;并注明 Salesforce 不对外提供自身数据的 OData,需要自己的驱动。 - **为什么改:** epic #27261 P2:驱动 #27741 已落地,但没有一页文档说明怎么用它、何时该用它。 - **风险与回滚:** 只改文档和 driver-odata 的 README(附一个 patch changeset),不改任何代码与 schema;回滚即 revert。 - **先看:** `content/docs/data-modeling/odata-datasources.mdx`;`virtual-objects.mdx` 的「Which way in」一节。 - **需要您做的:** 无。另有一处发现见下方 Acceptance notes:`os serve` 不把自己的密钥存储交给 stack 里的插件,OData 插件在 `os serve` 下只能用宿主自带的 `resolve`。 ## What changes - **New page `content/docs/data-modeling/odata-datasources.mdx`** (nav: after External datasources). Sections: check the service (v4 only, a key per entity set, the auth it takes); mount `createODataDriverPlugin` in the stack's `plugins` with its option table; declare the datasource and bind objects (an `os:check` example: `schemaMode: 'external'`, `driver` equal to the plugin name, `remoteName` as the entity set, `columnMap`, `id` as the entity key, lazy `$metadata`, `checkOnBoot: false`); credentials through `external.credentialsRef` only (bearer, basic, none; resolved at the first request and again after a 401; fail-closed; what `secrets` must be on a composed kernel and on `os serve`); what the service is asked behind the engine floor (`$filter` operators, what is left to the engine, the never-narrower rule, `$orderby` with the key, `@odata.nextLink` paging, the 1000-row cap); writes (double opt-in, POST / PATCH / DELETE by key, `If-Match`, refusal mapping, what is refused by name) and the sharing-model choice; the failure table; what the driver does not do. - **`virtual-objects.mdx`:** "Virtual, or a real object?" becomes "Which way in: OData, a plugin driver, or a real object", opening with the card's three rules in order (slow-changing reference data, then OData v4, then a plugin driver) above the existing copy-or-not table. A paragraph says Salesforce publishes no OData service for its own records, so it takes a plugin driver over its REST API. The SAP sales-orders example points to the OData page when the ERP publishes OData v4, and See also links it. - **`external-datasources.mdx`:** the opening callout and See also route OData v4 services to the new page. - **`meta.json`, `index.mdx` and `scripts/docs-audit/handwritten-docs.json`:** list the new page (the nav, the module index block, and the hand-written docs audit scope, regenerated with `check-audit-scope.mjs --write`). - **`packages/drivers/driver-odata/README.md`:** one cross-link to the guide, with a `patch` changeset (`.changeset/27747-odata-guide-readme-link.md`), since the README ships in the package. ## Why Epic #27261 P2 (ruling 6486145005, Tier B = declarative OData; 田騏 2026-10-29T21:58Z 「现在启动全部 P2」). #27741 shipped the driver (PR #27796, `0a4b0fb2`); no guide page showed an OData datasource or said when to choose declarative OData, a plugin driver, or a synced real object. ## Verification - **The examples parse against `main`'s schema.** The `os:check` block run through `defineStack` from `packages/spec/src` with `tsx`: it parses (one datasource, one object, `external` kept as written). The plugin options in the mount example run through `parseODataDriverConfig`: accepted. The four refusals the page names (credentials in `serviceRoot`, a query string, `version: '2.0'`, an unknown key) are each refused with the message the page describes. - **Every claim was read off the driver's source and tests** at `0a4b0fb2`: `config.ts`, `plugin.ts`, `transport.ts` (401 re-resolve, same-origin links, `Prefer: odata.maxpagesize`), `filter.ts` (the pushdown list and the never-narrower rules), `odata-driver.ts` (key as `id`, `columnMap` both ways, lazy `$metadata`, writes and refusals), and the floor and conformance tests. The `os serve` statement was read off `packages/cli/src/commands/serve.ts` (the binder is built inline and passed only to `DatasourceAdminServicePlugin`) and `service-datasource` (no service exposes it). - **Local gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 88 families from the changed files (71 before the changeset, index row and audit-scope row were added); every one was run, exit codes recorded before any pipe. First pass: `check-section-landing-index` and `check:docs-audit-scope` red on the new page (the module index row and `scripts/docs-audit/handwritten-docs.json`), both fixed; `check:skill-examples` (which type-checks the page's `os:check` block), `check:dual-build-cjs-loads` and `check:lean-entry-closure` exited 3 until the packages were built. Final pass, after building `./packages/**`: `dispatch-gates --ran`: "88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED (a DERIVED zero)". `origin/main` merged at `c62ec8e2` (none of the 12 incoming commits touched these files); `check:doc-anchors`, `check-section-landing-index` and `check:docs-audit-scope` re-run green on the merged head. The full battery is CI's. ## Acceptance notes - **Finding: `os serve` gives a stack's OData plugin no way to reach its secret store.** `os serve` builds its `sys_secret` binder inline (`serve.ts`) and passes it only to `DatasourceAdminServicePlugin`; no kernel service exposes it. So under `os serve`, `createODataDriverPlugin({ secrets })` cannot be handed the binder that resolves a `sys_secret:` handle, and the page tells the reader to pass a `resolve` backed by the host's own secret handling. Reported to the epic PM to decide whether it is a card (and whether #27746's credential seam covers it); this PR documents what the runtime does today. - **Left out on purpose, per the Claim:** the `external.connector` mention (#27786's), and the pointer to #27744's conformance kit (#27798 is not on `main`; the epic PM moves it to #27786 if it lands after this PR is ready). - **Not covered by tests, and the page says so:** the boot-time drift check (`checkOnBoot: true`) over an OData service. - **Worktree name:** the Claim names `objectstack-issue-27747`; the worktree is `objectstack-27747` on the claimed branch. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; no source file changed (`pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fzEsUhDQ2VCWQJ5eu --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7591c60 commit 6e53003

8 files changed

Lines changed: 331 additions & 7 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/driver-odata': patch
3+
---
4+
5+
docs(driver-odata): the README links the OData datasources guide
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The package README now points to the OData datasources guide (`/docs/data-modeling/odata-datasources`), which covers checking a service, mounting the plugin, binding objects to entity sets, credentials through `external.credentialsRef`, what the service is asked behind the engine's floor, writes, and how failures reach the caller. No code changes.

‎content/docs/data-modeling/external-datasources.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ auto-connected, validated at boot, and queryable — with no application code.**
1717
This guide is about reading/writing the *live* remote tables (federation). To
1818
*copy* rows into ObjectStack-owned tables instead, see seed data / data sync. For
1919
the plain multi-datasource routing of **managed** databases ObjectStack owns, see
20-
[Database Drivers](/docs/data-modeling/drivers). When the remote is not a database a
21-
built-in driver reaches — a REST API, a planning engine, a device gateway — serve the
22-
object from your own plugin driver instead: [Virtual objects](/docs/data-modeling/virtual-objects).
20+
[Database Drivers](/docs/data-modeling/drivers). When the remote is an OData v4 service
21+
(SAP S/4HANA, Dynamics 365), bind it through the shipped OData driver:
22+
[OData datasources](/docs/data-modeling/odata-datasources). When it is neither — a REST
23+
API, a planning engine, a device gateway — serve the object from your own plugin driver
24+
instead: [Virtual objects](/docs/data-modeling/virtual-objects).
2325
</Callout>
2426

2527
## 1. Declare the datasource
@@ -312,6 +314,8 @@ default allows all connects (subject to the gating above).
312314
- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing,
313315
[read-only via database privileges](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata),
314316
and [why the platform does not route read replicas](/docs/data-modeling/drivers#read-replicas-the-platform-does-not-route).
317+
- [OData datasources](/docs/data-modeling/odata-datasources) — the same binding over an
318+
OData v4 service, through the shipped OData driver.
315319
- [Virtual objects](/docs/data-modeling/virtual-objects) — the same binding served by
316320
a plugin driver you write, for a remote that is not a database.
317321
- [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field.

‎content/docs/data-modeling/index.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ That one definition is enough to get a persisted table, CRUD + query endpoints,
6060
<Card href="/docs/data-modeling/seed-data" title="Seed Data & Fixtures" description="Ship demo and reference data with your app" />
6161
<Card href="/docs/data-modeling/import-mappings" title="Import Mappings" description="Named column-to-field projections applied by the import endpoint" />
6262
<Card href="/docs/data-modeling/external-datasources" title="External Datasources" description="Federate external databases into the model" />
63+
<Card href="/docs/data-modeling/odata-datasources" title="OData Datasources" description="Bind an OData v4 service's entity sets, such as SAP S/4HANA or Dynamics 365, as live objects" />
6364
<Card href="/docs/data-modeling/drivers" title="Database Drivers" description="Configure SQL, MongoDB, in-memory, and WASM drivers" />
6465
<Card href="/docs/data-modeling/virtual-objects" title="Virtual Objects" description="Serve an object's rows from your own server through a plugin driver" />
6566
<Card href="/docs/data-modeling/analytics" title="Analytics Datasets" description="Model datasets for dashboards and reports" />

‎content/docs/data-modeling/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"seed-data",
1818
"import-mappings",
1919
"external-datasources",
20+
"odata-datasources",
2021
"drivers",
2122
"virtual-objects",
2223
"analytics"
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
---
2+
title: "OData datasources — ERP records, live, with no driver of your own"
3+
navTitle: OData Datasources
4+
description: 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.
5+
---
6+
7+
Many business systems already publish their records over **OData v4**: SAP S/4HANA,
8+
Microsoft Dynamics 365 and Dataverse, and any service that serves a CSDL 4.0
9+
`$metadata` document. For those you need no driver of your own. Mount
10+
`@objectstack/driver-odata` once per service, declare a datasource, and bind an object
11+
to each entity set you want. Every view, form, permission set, formula and hook then
12+
works on those records, and no row is stored on the platform side.
13+
14+
The OData driver is a **plugin driver**, so everything [Virtual objects](/docs/data-modeling/virtual-objects)
15+
says about plugin drivers holds here too: the engine re-applies every query, a read
16+
matches at most 1000 rows, and the sharing model needs a deliberate choice. The one
17+
difference is that this driver is already written.
18+
[Which way in](/docs/data-modeling/virtual-objects#which-way-in-odata-a-plugin-driver-or-a-real-object)
19+
says when to choose it over a driver of your own or a synced copy.
20+
21+
Everything on this page is what the driver's own tests assert, against an in-process
22+
OData service and behind the real engine
23+
(`packages/drivers/driver-odata/src/odata-driver.floor.test.ts` and
24+
`odata-driver.conformance.test.ts`). Read them when this page and the runtime seem to
25+
disagree.
26+
27+
## Check the service first
28+
29+
- **It speaks OData v4.** Open `<serviceRoot>$metadata`. The root element must say
30+
`Version="4.0"`. Only the v4 dialect ships. A service that speaks only OData v2 needs a
31+
v2 dialect, which this package does not include. Many older SAP Gateway services are
32+
v2 only, so check each service you plan to bind.
33+
- **Each entity set you bind has a key.** The key becomes the record's `id`. An entity
34+
type with no usable key is refused at the first request.
35+
- **You know how it authenticates.** The driver sends no credential, a bearer token, or
36+
HTTP basic. It does not run an OAuth2 flow ([Credentials](#3-credentials)).
37+
38+
## 1. Mount the plugin
39+
40+
The plugin builds the driver from its options and publishes it as the service
41+
`driver.<name>`. Mount it in the stack's `plugins`, one plugin per service:
42+
43+
```typescript
44+
import { defineStack } from '@objectstack/spec';
45+
import { createODataDriverPlugin } from '@objectstack/driver-odata';
46+
import { erpSecretStore } from './secrets'; // your host's secret store; see Credentials
47+
48+
export default defineStack({
49+
// …the datasource and objects of step 2…
50+
plugins: [
51+
createODataDriverPlugin({
52+
name: 'erp', // = the datasource name
53+
serviceRoot: 'https://erp.example.com/odata/v4/sales/',
54+
auth: { scheme: 'bearer' },
55+
secrets: erpSecretStore,
56+
}),
57+
],
58+
});
59+
```
60+
61+
| Option | Default | Meaning |
62+
| :--- | :--- | :--- |
63+
| `name` | `odata` | The datasource it serves. It is also the driver name and the `driver.<name>` service suffix. |
64+
| `serviceRoot` | (required) | The service root URL, http or https, with no credentials and no query string. |
65+
| `version` | `4.0` | The protocol version. `4.0` is the only value. |
66+
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. |
67+
| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. Required for `bearer` and `basic`. |
68+
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
69+
| `requestTimeoutMs` | `30000` | The timeout of each request. |
70+
71+
The options are checked when the plugin is built. An unknown key, a `serviceRoot` with
72+
a user name or a query in it, or a version other than `4.0` refuses the boot with every
73+
problem named.
74+
75+
## 2. Declare the datasource and bind objects
76+
77+
{/* os:check */}
78+
```typescript
79+
import { defineStack } from '@objectstack/spec';
80+
81+
export default defineStack({
82+
manifest: { id: 'com.example.sales', name: 'Sales', version: '1.0.0', type: 'app' },
83+
datasources: [
84+
{
85+
name: 'erp',
86+
label: 'ERP sales service (OData v4)',
87+
driver: 'erp', // the plugin's name, i.e. the `driver.erp` service
88+
schemaMode: 'external',
89+
config: {}, // the plugin's options configure the driver, not this
90+
external: {
91+
allowWrites: true,
92+
credentialsRef: 'sys_secret:7c1e…', // the bearer token, as a secret-store handle
93+
validation: { onMismatch: 'warn', checkOnBoot: false },
94+
},
95+
},
96+
],
97+
objects: [
98+
{
99+
name: 'sales_order',
100+
label: 'Sales order',
101+
datasource: 'erp',
102+
sharingModel: 'public_read_write', // see "Choose the sharing model" below
103+
external: {
104+
remoteName: 'SalesOrders', // the entity set
105+
writable: true,
106+
// remote property → local field; a property not named keeps its own name
107+
columnMap: {
108+
SalesOrderID: 'order_number',
109+
CustomerName: 'customer_name',
110+
NetAmount: 'net_amount',
111+
OverallStatus: 'status',
112+
DeliveryNote: 'delivery_note',
113+
},
114+
},
115+
fields: {
116+
order_number: { type: 'text', label: 'Order' },
117+
customer_name: { type: 'text', label: 'Customer' },
118+
net_amount: { type: 'number', label: 'Net amount' },
119+
status: { type: 'text', label: 'Status' },
120+
delivery_note: { type: 'text', label: 'Delivery note' },
121+
},
122+
},
123+
],
124+
});
125+
```
126+
127+
What each piece does on this driver:
128+
129+
- **Three names stay equal:** the plugin's `name`, the datasource `name`, and the
130+
datasource `driver`. The connect step finds `driver.erp` already registered and uses
131+
it as it is. `config` is left empty, because the plugin's options already configured
132+
the driver.
133+
- **`remoteName` names the entity set.** Without it the driver looks for an entity set
134+
named like the object.
135+
- **This driver applies `external.columnMap` itself**, in both directions: rows arrive
136+
under your field names, and filters, sorts and writes go out under the service's
137+
property names. That is a property of this driver, not of plugin drivers in general.
138+
- **`id` is the entity key.** A single-property key is read as text (`'4711'`). A
139+
composite key is the body of its key predicate (`'OrderID=1,ProductID=7'`), which is
140+
also what `GET /data/<object>/<id>` takes.
141+
- **Declare each field by hand.** Nothing generates fields from `$metadata` today.
142+
Navigation properties are never followed, so a related entity set is an object of its
143+
own.
144+
- **The service document is read lazily.** `connect()` asks the service nothing, and
145+
`$metadata` is read once, at the first request. A wrong `serviceRoot` or an unknown
146+
entity set shows up on the first read of the object, not at boot.
147+
- **`checkOnBoot: false`** keeps the boot from depending on the service being up. The
148+
driver's tests do not cover the boot-time drift check over an OData service, so test
149+
it against your service before you turn it on.
150+
151+
## 3. Credentials
152+
153+
⛔ Never put a credential in the stack, in the plugin options, or in the `serviceRoot`
154+
URL. The secret is the datasource's `external.credentialsRef`, and the plugin resolves it
155+
through the `secrets` store you pass:
156+
157+
- **`bearer`** sends `Authorization: Bearer <secret>`.
158+
- **`basic`** takes `username` from the options and the password from the secret.
159+
- **`none`** sends nothing. A `credentialsRef` declared beside `none` is refused, so a
160+
credential you meant to send is never left out by mistake.
161+
162+
The plugin resolves the secret at the **first request**, not at boot, and again after a
163+
`401`. So a token rotated in the secret store is picked up by the next request that the
164+
old token fails. The driver does not obtain or refresh tokens itself. A service that
165+
issues short-lived OAuth2 access tokens needs something that keeps the stored secret
166+
current.
167+
168+
`secrets` is any object with `resolve(credentialsRef)`, answering the secret or
169+
`undefined`:
170+
171+
- **A host that composes its own kernel** passes the same binder it gives
172+
`DatasourceAdminServicePlugin`. A `sys_secret:<id>` handle that the Add Datasource
173+
wizard minted then resolves, as on any federated datasource
174+
([Credentials](/docs/data-modeling/external-datasources#4-credentials)).
175+
- **`os serve`** builds its secret store internally and does not hand it to a stack's
176+
plugins. Pass a `resolve` backed by your host's own secret handling instead, and
177+
declare `credentialsRef` as a handle that it resolves.
178+
179+
Resolution fails closed. Each of these refuses the request rather than send it without
180+
the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with no
181+
`secrets`, and a handle that does not resolve. The caller is answered
182+
`503 ERR_DATASOURCE_UNAVAILABLE`, and the cause goes to the server log.
183+
184+
## 4. What the service is asked
185+
186+
Behind the floor, a list read is one `GET` on the entity set:
187+
188+
```text
189+
GET <serviceRoot>SalesOrders?$filter=OverallStatus eq 'open'&$orderby=NetAmount desc,SalesOrderID&$top=1001
190+
Prefer: odata.maxpagesize=500
191+
```
192+
193+
- **`$filter`** carries every part of the `where` the driver can translate: `$eq`,
194+
`$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$null`, `$contains`,
195+
`$startsWith`, `$endsWith` and `$icontains` (ASCII comparands), under `$and` and `$or`.
196+
- **The rest is left to the engine**, which re-applies the whole query to the rows the
197+
service returns: `$not`, `$like`, `$exists`, field references, and ordering or
198+
negation on text. The driver only ever asks a **wider** question. It drops a member of
199+
an `$and` it cannot translate, and drops a whole `$or` when one branch cannot be
200+
translated, so the service never returns fewer rows than match. This is also why a
201+
service that compares text case-insensitively still answers correctly.
202+
- **`$orderby`** carries the sort, then the entity key, so pages partition the result.
203+
- **Paging** follows `@odata.nextLink` until 1001 rows are read. A link to another
204+
origin is refused, so the credential never goes to a host you did not configure.
205+
- **The page size and the total** are the engine's, applied to the rows read. A count
206+
is computed the same way. The engine aggregates in memory.
207+
208+
**The 1000-row cap applies.** A read that matches more than 1000 rows answers
209+
`422 EXTERNAL_ROW_CAP_EXCEEDED` rather than a partial set
210+
([the cap](/docs/data-modeling/virtual-objects#the-engine-re-applies-every-query)). Every
211+
`$filter` the driver can push down makes the service return only matching rows, so the cap
212+
counts matching rows, not the whole entity set. Give list views a filter the service
213+
can run. For an entity set users must browse whole, mirror it into a real object
214+
instead.
215+
216+
## 5. Writes
217+
218+
Writes are the same double opt-in as federation: `external.allowWrites` on the
219+
datasource and `external.writable` on the object
220+
([Writes](/docs/data-modeling/external-datasources#5-writes-double-opt-in)).
221+
222+
| The platform does | The service is sent |
223+
| :--- | :--- |
224+
| `POST /data/sales_order` | `POST SalesOrders` |
225+
| `PATCH /data/sales_order/:id` | `PATCH SalesOrders(<key>)` |
226+
| `DELETE /data/sales_order/:id` | `DELETE SalesOrders(<key>)` |
227+
228+
- **`If-Match`** carries the entity's `@odata.etag` when the driver's last read of that
229+
record returned one, so a write over someone else's change is refused by the service.
230+
- **Only properties the entity type declares are sent.** The fields the engine stamps
231+
itself (`updated_at`, `updated_by`) are not the service's and are left out.
232+
- **The service's refusal reaches the caller.** A `400`, `409`, `412` or `422`
233+
from the service answers `422 EXTERNAL_WRITE_REFUSED` with the service's own message.
234+
Let the ERP's validation be the rule, and do not retry around it.
235+
- **Refused by name:** `upsert`, the bulk writes, and transactions. Without `$batch`
236+
there is no atomic multi-row write. A write that selects its rows by a filter is
237+
refused too. Read the ids, then write each one.
238+
239+
### Choose the sharing model
240+
241+
A record from an OData service has no `owner_id` and no `created_by`, so the default
242+
sharing model admits no row for a member, and an owner-keyed write is refused. The rule
243+
and the ways out are the same as on any plugin driver
244+
([Choose the sharing model on purpose](/docs/data-modeling/virtual-objects#3-the-datasource-the-object-the-hook-the-views)):
245+
246+
- **Read-only data:** leave `allowWrites` off and declare `sharingModel: 'public_read'`.
247+
Who may read is the permission sets' decision.
248+
- **Editable data:** declare `sharingModel: 'public_read_write'`, and decide who may edit
249+
in a `beforeUpdate` hook. A member's delete is still refused on this model.
250+
251+
## When the service fails
252+
253+
| What happened | The caller is answered |
254+
| :--- | :--- |
255+
| 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 |
256+
| A write the service refuses | `422 EXTERNAL_WRITE_REFUSED`, with the service's message |
257+
| 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 |
258+
| A read that matches more than 1000 rows | `422 EXTERNAL_ROW_CAP_EXCEEDED` |
259+
260+
An outage is never answered as an empty list. A read refusal's message is withheld, and
261+
goes to the server log, when the read carries a predicate the caller did not write, such
262+
as a sharing scope. The full mapping is in
263+
[Virtual objects](/docs/data-modeling/virtual-objects#1-the-driver).
264+
265+
## What it does not do
266+
267+
- **OData v2.** Only the v4 dialect ships.
268+
- **`$expand` and navigation properties.** Bind each related entity set as an object of
269+
its own.
270+
- **Actions and functions** of the service. `execute` is refused.
271+
- **Transactions and `$batch`.** Each write is one request.
272+
- **More than 1000 matching rows** in one read (the cap above).
273+
- **A read scoped by a parent's access** (an object `controlled_by_parent`): it is
274+
refused, because `$filter` has no form of that join.
275+
276+
## See also
277+
278+
- [Virtual objects](/docs/data-modeling/virtual-objects): which way in, the engine's
279+
floor, the sharing-model callout, and writing a driver for a system with no OData.
280+
- [External datasources](/docs/data-modeling/external-datasources): the datasource and
281+
`external` binding, federation over SQL, the write gates and credentials.
282+
- [Pulling a mapping](/docs/automation/jobs#pulling-a-mapping): a real object fed from a
283+
remote API on a schedule.
284+
- `packages/drivers/driver-odata/README.md`: the package and its exports.

0 commit comments

Comments
 (0)