npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cap-ts/remote-service-adapter

v0.2.6

Published

Remote service Adapter for CAP

Readme

@cap-ts/remote-service-adapter

Version npm

A CAP service implementation that turns CDS projections and views into working OData endpoints over remote backends (read, write, actions), without per-entity handler code. @sap/cds ^9.9.1 or ^10, plain CommonJS output, TypeScript typings included.

📦 About

@cap-ts/remote-service-adapter exports one class, RemoteApplicationService. Make it the implementation of a CDS service (annotate the service @remote, or extend the class) and every entity of that service is served from the entity's source: a remote OData V2 / V4 service, a SOAP service (via @cap-ts/soap-adapter), another CAP service of the same application, or the database. The service reads the mapping from the projection itself (aliases, calculated columns, association paths, structured fields, the projection's where) and translates every request to the backend's names and every answer back.

What you get without writing a handler:

| Area | Features | | --- | --- | | Reading | Hybrid $filter push-down, field aliases, calculated columns, association path columns, structured fields, the projection's own where, $expand across services, $search, $orderby, paging, $count, DISTINCT / GROUP BY / aggregates, JOIN views, static data | | Filters through associations | _A/_B/Field eq 'v' and OData any() through to-one and to-many associations, answered by key semi-joins instead of full reads | | Reads by key | A filter that pins the source key (or lists several keys) becomes one read by key per key; unknown keys answer 404 | | Writing | CREATE / UPDATE / DELETE on the projection written to its source; write rules checked before the backend is written; deep writes; non-CRUD APIs (every change a POST) | | Operations | Bound and unbound actions / functions forwarded to the source operation | | Backend quirks | Opt-in annotations for fields a backend cannot filter, flags it cannot compare, date-time fields exposed as dates | | Operations support | [remote] log line of every backend request, request-scoped tracing, secret redaction |

It has no authentication or authorization model of its own (CAP's @requires / @restrict apply as usual), no business logic and no override seams: bespoke behavior goes into ordinary CAP handlers of your service.


📑 Table of Contents

📥 Installation
🧭 How it works
⚡ Quick start
🏷️ Annotation reference
📝 Modelling reference
🔍 Reading data
🔗 Filters through associations
🔑 Reads by key
🧠 Caching
✏️ Writing data
⚙️ Actions and functions
🔌 Class API
🧩 CAP plugin: the @remote annotation
🧼 SOAP backends
🛠️ Configuration and logging
🚦 Errors and results
🚀 Performance guide
🔒 Boundaries and security
🩺 Troubleshooting
⚠️ Known limitations
💬 Support and feedback
📄 License


📥 Installation

↑ Table of Contents

npm install @cap-ts/remote-service-adapter

The package registers itself as a CAP plugin (cds-plugin.js): no require() is needed for the @remote annotation. cds watch and cds build pick it up. Importing the class (import { RemoteApplicationService } from '@cap-ts/remote-service-adapter') works either way.

| Peer | Version | Notes | | --- | --- | --- | | @sap/cds | ^9.9.1 \|\| ^10 | CAP runtime. Node.js >= 20 (cds 10 needs >= 22). | | @cap-ts/soap-adapter | ^0.4.0 | Used at runtime only for services of kind: 'soap'; a required peer because the SOAP dispatch imports it. | | @sap-cloud-sdk/connectivity, @sap-cloud-sdk/http-client | ^4.7.0 | Destination lookup and HTTP for @cap-ts/soap-adapter. | | @cap-js/cds-types | >=0.18.0, optional | TypeScript types for @sap/cds. |

No runtime dependencies of its own (everything else is a peer or a Node.js built-in).


🧭 How it works

↑ Table of Contents

Client (OData V4 / Fiori)
      │
      ▼
Your CAP service (implementation: RemoteApplicationService, via @remote or a subclass)
      │  READ / CREATE / UPDATE / DELETE / actions of every entity
      ▼
RemoteApplicationService: query in local names → query in source names → backend → rows in local names
      │  cds.connect.to() / a service of this application / cds.db / soap.read()
      ▼
Backends: OData V2 / V4, SOAP (@cap-ts/soap-adapter), other CAP services of this application, the database

For every request the service:

  1. Splits the $filter into the part the backend can evaluate (pushed) and the rest (evaluated in memory).
  2. Translates the pushed part, $select, $orderby, keys and paging to the source's names.
  3. Sends the query to the service that owns the source entity (paging through the backend when it needs every row).
  4. Maps the answer back: aliases, calculated columns, structured fields, association paths, $expand.
  5. Applies what was not pushed (filter, $search, sort, paging, $count) and prunes to the requested shape.

Services can be stacked: a projection on an entity of another @remote service is served the same way, and each layer pushes down what it can to the layer below.


⚡ Quick start

↑ Table of Contents

1. Configure the external service (package.json or .cdsrc.json):

{
  "cds": {
    "requires": {
      "RemoteOrders": {
        "kind": "odata-v4",
        "model": "srv/external/RemoteOrders",
        "credentials": { "destination": "ORDERS_DESTINATION" }
      }
    }
  }
}

kind is odata, odata-v2, odata-v4 or soap. It decides the $search case rule and, for soap, the dispatch.

2. Declare the projection service, annotated @remote:

using { RemoteOrders } from './external/RemoteOrders';

@remote
service OrderService {
    entity Orders as projection on RemoteOrders.Orders {
        key ID,
        OrderName as Title,              // alias: local Title <-> remote OrderName
        Amount,                          // same name
        _Customer.Name as CustomerName,  // to-one association path
        _Items                           // association, for $expand
    };
}

3. Run cds watch. GET /odata/v4/order/Orders is live: no handler file.

Alternative: extend the class, when the service also needs handlers of its own:

// srv/OrderService.ts
import { RemoteApplicationService } from '@cap-ts/remote-service-adapter';

export class OrderService extends RemoteApplicationService {
    async init(): Promise<void> {
        // Handlers registered here run BEFORE the generic ones (READ, writes, operations of every entity).
        this.before('CREATE', 'Orders', (req) => { /* validation, derived values */ });
        await super.init();
    }
}

With a handler file (or @impl, or cds.requires.<Service>.impl), the plugin leaves the service alone.


🏷️ Annotation reference

↑ Table of Contents

Every annotation goes on the definition it describes, in the local (projection) service.

| Annotation | On | Effect | Section | | --- | --- | --- | --- | | @remote | service | Makes RemoteApplicationService the service's implementation (CAP plugin). | CAP plugin | | @response.data: [ {...} ] | entity | Static rows: returned as they are, no backend call. Not writable. | Static data | | @remote.paging: false | entity / service | A read of every row of a remote source (in-memory filter, no limit) is one request, as before this package paged such reads. For a backend that can not page. | Reading data | | @remote.filter.local | element / entity | Request $filter and $search on the element run in memory. On the entity: every element whose source field is @sap.filterable: 'false'; @remote.filter.local: false on an element opts it out. | Fields the backend cannot filter | | @remote.cache: true / false, @remote.cache.ttl: <seconds> (@remote.cache: { ttl: 60 }) | entity / service | Opt-in cache of reads by key of the entity (service: all its entities); false on an entity opts out. For a GROUP BY / DISTINCT entity it also enables the aggregate cache. | Caching | | @remote.warmUp, @remote.warmUp: { where, every, limit, enabled }, @remote.warmUp: [ {...}, ... ] | entity | Read the entity when the server starts and again before the caches the read filled expire, so users find them filled. where: a CDS expression or a string; one read per record. | Warm-up | | @remote.search.maxRows: <n> | entity | Opt-in: a $search that matched more than n rows of this entity, whose association paths must be resolved, is refused (502) instead of read. | Reading data | | @remote.pushdown: false | calculated element | Filters on the element stay in memory (no boolean-flag push-down). | Calculated columns | | @remote.operation: 'Name' / '<Service>.<Name>' | action / function | Forwards to the bound operation Name of the source entity, or to the unbound operation of another service. | Actions and functions | | @remote.write.asInsert: ['UPDATE', 'DELETE'] | entity | Sends these events as INSERT (POST) with the source keys in the body. | Non-CRUD backends | | @remote.write.operation: { field, CREATE, UPDATE, DELETE } | entity | Sets the source field field to the event's code in the body. | Non-CRUD backends | | @remote.write.truncate | entity / element | Cuts strings to the element's length before the write (false on an element opts out). | Writing data | | @remote.write.deep | association | Data of the association is written with the parent (upsert per child row). | Deep writes | | @remote.assert: (case when <cond> then '<message>' end) | element | Check evaluated before the backend write; the first matching when is the error. | Write rules | | @remote.assert.args: [ ... ] | element | Message arguments ({0}, {1}, ...) of @remote.assert: paths or (<expression>). | Write rules | | @remote.assert.target | to-one association | The target must exist (ASSERT_TARGET). | Write rules | | @remote.write.value: (<expression>) | element | Value computed before the write when the data has none. | Write rules |

Standard annotations the service reads:

| Annotation | Effect | | --- | --- | | @cds.search: false / true (element), @cds.search: { A, B } (entity) | Which columns $search uses. | | @cds.query.limit: { default, max } (source entity or its service) | Page size when every source row is read (GROUP BY, DISTINCT, association fetches). | | @sap.filterable: 'false' (source element, from the EDMX) | Terms of the projection's own where on it are evaluated in memory; request filters with @remote.filter.local. | | @odata.Type: 'Edm.Date' (local element) | A date-time source value is delivered as a date, date literals are compared as days. | | @mandatory (child element) | Checked on every child row of a deep write (CAP checks the root only). | | @readonly | CAP drops client values; your handler may set them (backend-only values). |


📝 Modelling reference

↑ Table of Contents

The local ↔ remote mapping comes from the projection's columns. Never hard-code remote field names in TypeScript.

entity Orders as projection on RemoteOrders.Orders {
    key ID,
    OrderName as Title,                           // alias
    Amount,                                       // same name
    virtual Flag : Boolean,                       // never requested from the backend
    left(OrderName, 2) as Prefix : String(2),     // calculated in memory
    case when Status = 'X' then true else false end as IsClosed : Boolean,   // boolean flag, filter pushed
    Address.City as City,                         // field of a structured element
    _Customer.Name as CustomerName,               // to-one association path, any depth
    _Items,                                       // association, $expand
    _Customer : redirected to Customers           // association to another entity of this service
} where Kind = 'A';                              // the projection's own WHERE, applied to every read and write

| Element | Behavior | | --- | --- | | Remote as Local | Translated both ways. A remote field exposed under several names (* plus Field as Alias) is requested once and fills every name. | | virtual | Never requested; filters on it run in memory. | | Calculated (<expr> as Name) | Computed in memory from the source values: a CASE, or one function call (left(Field, 2) reads the source's Field even when the projection renames it). Its source fields are requested automatically. Never sent to the backend. | | Boolean flag (case when Field = 'v' then true else false end) | Computed in memory; a filter Flag = true / false is pushed as Field = 'v' / Field != 'v'. | | exists flag (case when exists _Items then true else false end) | The association is read for the rows of the answer, then the flag is computed in memory. See Association flags (exists). | | Structured field (Struct.Field as X) | The backend is asked for the structure, the answer flattened. Filters on it run in memory. Written back as { Struct: { Field } }. | | Path column (_A._B.Field as X) | Resolved across to-one associations at any depth, one batched key fetch per hop. A to-many hop or filtered segment requested explicitly is 501. | | Association | Declare the ON condition (or managed keys). Used for $expand, path columns, filters through associations, write rules and deep writes. | | Date element over a date-time source field | Delivered as yyyy-mm-dd; date literals compared with it are sent as day ranges. | | String(n) | A $search word longer than n is not pushed for that column. |

Static data

@response.data: [{ ID: 'A', Name: 'Alpha' }, { ID: 'B', Name: 'Beta' }]
entity Codes as projection on RemoteOrders.Codes { key ID, Name };

Returned without a backend call, never written. A request $filter (and $count) is evaluated on the rows in memory, like any filter on a projection that cannot be pushed.

JOIN views

entity OrderLines as select from RemoteOrders.Orders as o
    join RemoteOrders.Items as i on i.OrderID = o.ID and i.Status = 'Active'
    left join RemoteCustomers.Customers as c on c.ID = o.Customer {
        key i.ID, o.Title, i.Quantity, c.Name as CustomerName, i.Quantity * i.Price as Total : Decimal(15, 2)
} where o.Kind = 'STD';

Sources of any kind (remote OData, SOAP, CAP-served, the database), inner and left joins, any number of them:

  • The terms of the request's $filter (and of the view's own where) that read plain columns of one source are pushed to that source (not to the right side of a left join, not != / not, not when the filter has a top-level or), as are the constants an ON condition sets for the source it joins (i.Status = 'Active').
  • The inner joins before the first left join start at their most restricted source (the key of a read by key first, then the request's filter, the view's where, ON constants): OrderLines('I1') reads the item I1 first, then its order by the join key. From the first left join on, the sources are read in the order of the FROM. Every next source is read by the join keys of the rows found so far (in lists of 200 values).
  • Each source contributes at most cds.query.remote.join.maxRows rows (default 5000, read in pages of its @cds.query.limit.max); more is a 502 asking for a $filter.
  • The rows are joined here (inner: every ON condition holds, constants included; left: a row without partner keeps its columns null), then the view's columns (plain columns and expressions), its where, the request's $filter, $search, $orderby, $count, paging, $expand and $select apply. A read by key without row is 404.
  • 501: right / full / cross joins, a source joined without an equality to the others, a column or a filter that goes through an association, exists or a sub-select in a where. JOIN views are not writable.
  • An application with a database compiles every view to SQL: annotate a JOIN view @cds.persistence.skip when it joins a remote entity with one that has a table (an entity of the database, or of a SOAP service, which CAP deploys).

🔍 Reading data

↑ Table of Contents

$filter push-down

The WHERE is split at its top-level and. Each term is pushed to the backend when it only reads plain mapped fields; otherwise it is evaluated in memory after the fetch. Evaluated in memory: virtual, calculated, path and unmapped fields, structured fields, fields with @remote.filter.local, and (for SOAP) any function. The in-memory evaluator understands and / or / not, comparisons, [not] in, is [not] null, exists (OData any()), and the functions contains, startswith, endswith, indexof, tolower / toupper, length, trim, substring, concat, replace, left, right, coalesce / ifnull, round, floor, ceiling, abs, year, month, day.

Pushed in a backend-friendly form:

| Request term | Sent as | | --- | --- | | Prefix = 'AB' on left(Field, 2) as Prefix | startswith(Field, 'AB') | | IsClosed = true on case when Status = 'X' then true else false end as IsClosed | Status = 'X' (false: Status != 'X') | | Day >= 2026-09-28 on a Date element over a date-time source | Day >= 2026-09-28T00:00:00Z (only >= / <=; = becomes the day range) | | The same condition twice (e.g. from two stacked layers) | Once | | A literal comparison that is false ('1' = '2', written by CAP for a restriction whose $user attribute is missing) | Nothing: the answer is empty, the backend is not called |

If the backend rejects the pushed filter (an error with 400 or filter in it, or an association path complaint), the read is repeated without the filter and the whole WHERE is applied in memory.

A read whose filter is (partly) evaluated in memory needs every row the pushed part leaves: $top is not sent. Such a read, and every other read of a remote source without a limit (for example the read of a projection on another entity of this package, which asks that entity for every row), first asks for one page of the source's @cds.query.limit.max (entity, then service, then cds.query.limit.max, else 1000) with the count, in the backend's own order. When that is every row (the usual case) it is the answer. A longer answer is read again in pages ordered by the source key, up to pagesInParallel at a time: a single request for every row can exceed what the backend answers at once (S/4: HTTP 500 SYSTEM_NO_ROLL). One request without paging, as before: @remote.paging: false on the entity or its service, cds.query.remote.paging: false, a source without key, or a backend that rejects the page request (400).

A filter or sort on an element that $select leaves out works, also on a calculated or path element (its source fields are read): rows are filtered and sorted before they are pruned. A sort the backend does completely keeps the backend's order.

A projection on an entity of another service of this package (a layered model) may rename its elements (Item as ItemKey): columns, $filter, $orderby and keys go to that entity in its own names.

Fields the backend cannot filter

OData V2 services declare some fields sap:filterable="false" and ignore a $filter on them without an error (every row comes back). Opt in to in-memory evaluation:

@remote.filter.local                                   // every sap:filterable=false field of the source
entity Entries as projection on Remote.Entries {
    *,
    @remote.filter.local: false Note                   // ...except this one: pushed
};

entity Tasks as projection on Remote.Tasks {
    key ID, Owner,
    @remote.filter.local Label                         // just this element, whatever the EDMX says
};

The read then fetches every row the rest of the filter leaves, so opt in where that set is small (for example when another pushed term restricts it to one user's rows). Terms of the projection's own where on such fields are always evaluated in memory, without annotation.

The projection's own WHERE

entity OpenOrders as projection on RemoteOrders.Orders {
    key ID, Amount, _Customer.ValidTo as CustomerValidTo
} where Status != 'CANCELLED' and _Customer.ValidTo >= current_date;
  • Plain source fields are pushed with the request's filter (they need not be projected).
  • To-one association paths are resolved and evaluated in memory.
  • $user, $user.<attribute>, $now, current_date, current_time, current_timestamp take the request's values.
  • Reads by key re-check every term in memory; a row that fails is 404. Projections on top inherit the filter.
  • Writes respect it: CREATE fills Field = 'v' terms (a different value is 400), UPDATE / DELETE of a row outside is 404.
  • What can be neither pushed nor evaluated (exists, a sub-select, $at, a to-many or filtered path, like / between / an unknown function in an in-memory term) is 501, never ignored.

Paging and $count

  • $top / $skip go to the backend only when nothing is evaluated in memory afterwards; otherwise every row matching the pushed part is read and the page is cut here (logged once per entity as a warning on cds.log('remote')).
  • $count is always a number. It is the backend's count when nothing is filtered in memory, else the count after local processing. OData V2 sends its count as text; the service converts it.
  • A count only ($top=0&$count=true) is one GET <Entity>/$count?$filter=...: no rows move. Fallbacks: one row plus the total, then reading in pages. A count never reads every row in one request.
  • When every source row is needed (GROUP BY, DISTINCT, association fetches of a stacked service), the source is read in pages of its @cds.query.limit.max (entity, then service, then cds.query.limit.max, CAP default 1000), ordered by the source key, until the backend's total is reached. A backend that caps its pages below that size just needs more pages. Ending short of the total is a 502, never a partial result.
  • A page past the end returns [] with the full $count.
  • $orderby with $top / $skip: the backend sorts, so the page is the right one. The leading sort terms on plain elements are sent in the source's names (also CAP's implicit key order, which CAP adds to every paged OData request). A backend that rejects the sort (400) is asked again without it, and the page is sorted among itself. An explicit sort on a path or calculated element is applied here over every row, after one count request, for at most 5000 rows; above that the page is read as before and sorted among itself (logged on cds.log('remote')). CAP's implicit key order never causes a read of every row.
  • In-memory sorts compare numeric elements as numbers, also when the values arrive as strings.

$expand

  • Declare the association; $expand=_Items becomes batched <key> in (...) reads of the target (at most 200 parent keys per request), grouped per parent. Nested expands, $filter, $orderby and $count inside the expand work.
  • Children the backend delivered inline are mapped, filtered, limited and expanded further in place.
  • A target served by another RemoteApplicationService is read without a page limit, in pages up to its total.
  • A managed to-one association is joined on its foreign key elements (<assoc>_<key>); an association from an EDMX model without ON condition joins on the key names it shares with its target.
  • A backend that rejects the expand query is asked again without columns, filter and order.

Path columns

_A._B.Field as X is resolved after the main read: one batched key fetch per hop level for all rows. To-many hops and filtered segments are 501 when requested in $select, $filter or $orderby (left out otherwise). $orderby on a path sorts by the resolved value.

Columns of a handler's query

A handler that reads through the service (this.run(SELECT ...)) can name path columns, aliases and expressions, as with any CAP service:

const row = await this.run(SELECT.one(Orders).columns(
    'Amount as Total', 'Customer.Name', 'Customer.City as Town', { func: 'upper', args: [{ ref: ['Customer', 'Name'] }], as: 'Loud' }
).where(key));
// { Total: 10, Customer_Name: 'Acme', Town: 'Berlin', Loud: 'ACME' }

The source is read with plain columns: the elements these columns read, and an $expand of every association a path goes along (for every source kind: database, CAP-served, OData, SOAP, and JOIN views). The answer has them under CAP's names: the alias, else the path joined by _; null when the association has no target. Expressions are computed in memory after the read: literals, elements, to-one paths, ||, + - * / (a null operand gives null), CASE and the functions evaluated in memory (upper, lower, concat, substring, left, right, length, trim, replace, coalesce, round, floor, ceiling, abs, year, month, day, contains, startswith, endswith, ...).

| Column form (query) | Database / CAP-served source | Remote OData / SOAP source | | --- | --- | --- | | Element Field | Supported | Supported | | * | Supported | Supported | | Path along to-one associations Assoc.Field, A.B.Field | Supported, as Assoc_Field | Supported, as Assoc_Field | | Path with an alias Assoc.Field as X | Supported, as X | Supported, as X | | Alias on an own element Field as X | Supported, as X | Supported, as X | | expand: { ref: ['Assoc'], expand: [...] }, with or without alias | Supported, nested object / array | Supported, nested object / array | | Expression with alias: upper(Field) as X, A * B as X, A || '-' || B as X, CASE, 1 as One, paths inside | Computed in memory | Computed in memory | | Aggregates (count, sum, avg, min, max) | Handled by GROUP BY (see DISTINCT and GROUP BY) | Same | | Path over a to-many association, with an infix filter, ending on an association or structure | 501 naming the column | 501 naming the column | | inline: Assoc.{ Field } | 501 (name each path instead) | 501 (name each path instead) | | Expression without alias; a function or operator not listed above (comparison outside a CASE, exists, a sub-select); an expression as a function argument; $user / $now | 501 naming the column | 501 naming the column |

Path columns of the query are 501 on DISTINCT / GROUP BY projections (aliases and expressions are computed on the aggregated rows).

Calculated columns

Computed in memory on the source values: a CASE expression (simple or searched; conditions with comparisons, and / or / not, in, is null), or one function call (left(Field, 2), concat(A, B), coalesce(A, B), any function listed above, also inside an expression). Operators outside a CASE (A || B, A + B) are not computed: write concat(A, B), or compute the value in a handler. A calculated element of an association target served by another RemoteApplicationService is computed by that service. An association whose ON condition uses a calculated column is resolved with the computed value. @remote.pushdown: false keeps filters on a calculated column in memory, for a backend that mishandles the comparison of its source field (for example Status != 'X'). A calculated column that reads a path across a to-many association (coalesce(_Items.ID, 'none')) is 501 when requested in $select, $filter or $orderby: test the association with exists instead.

Association flags (exists)

@remote
service OrderService {
    @readonly entity Orders as projection on RemoteOrders.Orders {
        *,
        _Items : Association to many Items on _Items.OrderID = $self.ID and _Items.Status = 'Active'
    };
    @readonly entity Items as projection on RemoteOrders.Items;
}

@remote
service OrderOverview {
    @readonly entity Orders as projection on OrderService.Orders {
        *,
        case when exists _Items then true else false end as HasActiveItems : Boolean,
        case when exists _Items[Quantity > 10] then true else false end as HasLargeItems : Boolean,
        exists _Items._Product[Blocked = true] as HasBlockedProduct : Boolean
    };
}
  • exists <association> is true when the association has at least one row. Constant terms of its ON condition (_Items.Status = 'Active') and infix filters ([Quantity > 10]) apply. Paths of any depth work, through to-one and to-many associations.
  • Each association is read once for all rows of the answer, in batched key reads (one per hop and 200 parents), and only when the query asks for the flag.
  • $filter and $orderby on the flag are evaluated in memory, on every row the pushed part of the filter leaves.
  • A flag no filter or sort needs is computed after the page is cut: for the rows of the answer only.
  • A read that computes a flag for more than 5000 rows is refused (502, "narrow it down with $filter or $search"): the association of every row would be read. A read of every row (a filter on the flag, or a layer above that filters on it) is counted first, so it fails in about one request; with $search the rows the search leaves are checked. A filter Flag = true is answered by a semi-join instead (Filters through associations), so a projection where HasActiveItems = true over tens of thousands of orders works when the association's target is small enough for cds.query.remote.semiJoin.maxRows (with the key set cache, every later request answers from memory). The flag of the rows such a filter leaves is true without reading the association again, and the 5000-row limit does not apply to it.
  • A $expand of the same association keeps its own $select, $filter and $top.
  • The association may also be one of the source only, when the projection lists its columns and leaves it out (entity ActiveOrders as projection on OrderService.Orders { key ID, case when exists _Items then true else false end as HasActiveItems : Boolean }).
  • 501 when the query names the flag and it cannot be computed: the association is an element of neither the projection nor its source, an infix filter reads a path or contains exists, exists on a sub-select or inside a function call, or a DISTINCT / GROUP BY projection. A read without $select leaves such a flag out.

Structured fields

Address.City as City asks the backend for Address and flattens the answer. Filters, $search and the left() push-down on structured fields run in memory. Writes send { Address: { City } }.

$search

OData V4 syntax: words ANDed, OR, NOT, parentheses, "phrases". Searched: string elements (keys included), to-one path strings and calculated string columns; not LargeString, UUID, virtual, to-many paths, String(n) with n > 500. Tune with @cds.search. The case rule follows the backend the data comes from (cds.requires.<service>.kind): odata-v2 case-sensitive, odata / odata-v4 and local case-insensitive (tolower), soap matched here only (every row is read, case-insensitive, nothing is pushed). A backend that rejects tolower (S/4: Filter function 'TOLOWER' not supported) is remembered and searched case-sensitively: the first request is asked again without it, and one line is logged per service and process, [remote] - <Service> does not support the filter function tolower: $search on it is case-sensitive from now on (CAP's own warning with the backend's error is not logged for it).

A term with & must be quoted: CAP's OData parser ends an unquoted $search at &, also when it is sent as %26 ($search=A%26B searches for A). Send $search="A%26B" (%22A%26B%22).

A sound contains(field, word) filter is pushed where possible (including key lists of matching associated rows); the local match always has the final say. Without any push the read is bounded at 5000 rows; more is a 502 asking for a $filter that names why nothing was pushed, for example $search could not be pushed to the backend (the term can not be restricted: calculated column 'Label') and '<Entity>' has more than 5000 rows.

When the pushed filter is the search itself, the page goes to the backend as well: every searched column is a plain column, the term has no NOT and no word with *, + or %, and the filter is one query (no list of associated keys split over several). Then $top, $skip, $count and $orderby are sent with it and only the page is read, so a value help's type-ahead with a broad term reads ten rows, not every match. A backend that answers fewer rows than asked (server paging) is asked again for the rest. Otherwise every match is read, then matched, counted, sorted and paged here.

*, + and % are plain characters, as in CAP (no wildcards): cc* matches values that contain cc*. They are never sent to the backend (SAP Gateway and S/4 OData V4 reject them in contains): the longest piece between them is (cc), and a word of only these characters is not pushed. The rows of a pushed search are read in pages, so a backend that answers a fixed page size without $top (S/4 OData V4: 100) does not cut the result or $count. Parentheses group; an unmatched ( or ) standing alone ($search=() is a word and matches literally, one touching a word is ignored ((Acme = Acme). A lone " and & are rejected by CAP's OData parser (400) before the adapter is called.

When the entity projects on an entity of another service of this package (a layered model) and every SEARCHED column of the entity is a plain column, the search is handed to that service instead of being turned into a filter over its columns: the lower service knows which of its columns are path or calculated columns and plans the push-down (and the key pre-queries) itself, so a search through several layers reads what a search on the lowest layer reads. Columns that are not searched do not matter: an exists flag or another calculated column of the upper entity is computed for the rows the lower service returns. The upper layer does not bound its read (the lower layer pushes the search or bounds its own read) and matches the returned rows again. When both search the same columns (@cds.search) with the same case rule, the lower answer is exact and the page, count and sort are sent down with the search (see above).

Only a searched path or calculated column of the entity's own (a column the lower entity does not have, for example a path over an association of the lower entity) stops the hand-off: its push-down is planned in its own layer, over the lower entity's associations, and sent to the lower service as a plain filter. A term that matches more than 2000 associated rows cannot be turned into a key list; the search then falls back to the bounded local match (see above).

For a value help over layered entities: keep the searched columns plain (@cds.search: { ID, Name, ... } on columns the lowest entity has, leave out coded and calculated columns). Flags and other calculated columns may stay in the entity; they are not searched and do not slow the search down.

DISTINCT and GROUP BY

Only when the entity itself declares distinct / group by. Computed in memory over every source row (read in pages): count, count_distinct, sum, avg, min, max. When every aggregate is a row count and the $filter fixes every group column (= / in, at most 25 combinations), one $count request per combination replaces the read.

entity ItemCounts as projection on RemoteOrders.Items { key Category, Status, count(ID) as N : Integer } group by Category, Status;
annotate RemoteOrders.Items with @cds.query.limit: { default: 1000, max: 5000 };   // page size for reading every row

HAVING. A having of the projection filters the groups, for every read of the entity:

entity BusyStages as projection on RemoteOrders.Items { key Stage, count(*) as N : Integer }
  group by Stage having count(*) > 1 and Stage in ('OPEN', 'DONE');

Terms with an aggregate (count, sum, avg, min, max, also one that is not in the select list) are evaluated on every group after the aggregation. Terms on group columns only (Stage in (...)) are a where in effect: they go to the backend like the projection's own where, so fewer rows are read (evaluated here when the backend cannot filter that field). An aggregate of no value is null, and a comparison with null is false. between is supported in aggregate terms. A having the adapter cannot apply (no group by, a field that is neither a group column nor inside an aggregate, like, a function it cannot evaluate here, exists, a sub-select) is answered with 501 instead of unfiltered groups. A $filter on an aggregate column of the request narrows the groups that remain; $count is the number of groups after the having.


$expand and instance restrictions

CAP evaluates @restrict ... where only on the entity a request targets, not on expanded entities (CAP Node.js "Limitations"). An association that the backend can expand itself (a managed association of an imported OData service) is normally expanded by the backend, which can not apply a restriction that depends on the current user:

@(restrict: [{ grant: 'READ', where: (Field = $user.<attr>) }])
entity Targets as projection on Remote.Targets { ... };

An expand whose target (or a target further down) has a @restrict privilege for READ / * with a where is therefore not sent to the backend as $expand: its rows are read through the target's own service, where CAP applies the restriction. Other expands are unchanged; role-only restrictions are not affected.


🔗 Filters through associations

↑ Table of Contents

Filters may go several associations deep, through to-one and to-many associations:

GET /odata/v4/order/Orders?$filter=_Customer/_Group/Region eq 'EMEA'
GET /odata/v4/order/Orders?$filter=_Customer/_Contacts/any(c: c/_Mails/any(m: m/Address eq '[email protected]'))

The main entity's backend cannot evaluate these. Instead of reading every order and expanding the associations, the service answers them with semi-joins, hop by hop:

  1. The first association's target (Customers) is read with the rest of the condition (_Group/Region eq 'EMEA'), selecting only its join keys. Its own service answers that rest the same way, so a chain of any length resolves level by level.
  2. The main entity is filtered by the keys found:
    • at most 200 key values: pushed as CustomerID in (...) (backend $top and $count stay correct);
    • more (up to 5000): the same filter in memory; the main entity is read with one request per 200 keys (sent up to pagesInParallel at a time) when that takes fewer requests than reading all of its rows (one count request decides), else in full without any expand;
    • no key: an empty answer, the main entity is not read.
  3. The target is read in pages ordered by its key, up to pagesInParallel at a time, for at most 5000 rows (cds.query.remote.semiJoin.maxRows raises it). A page has 1000 rows (cds.query.remote.semiJoin.pageSize: the read selects only the join keys, so a backend that allows larger pages needs fewer requests). A target served by this application gets at most its own page limit (@cds.query.limit.max, else cds.query.limit.max, else 1000): CAP answers no more per read, and larger pages would be read one after the other). Above that, or when the target's $count does not prove the key list complete, the filter is evaluated in memory on hidden expands of the associations (correct, slower).
  4. When the target has more than one page of rows and the rest of the filter leaves at most 5000 rows of the main entity, the filter is evaluated on those rows if that takes fewer requests (their pages plus one association read per 200 rows) than reading the target's keys. One count request decides; the key set cache (Caching) skips it.

A filter Flag = true on an exists flag (case when exists _Items[Status = 'Open'] then true else false end, or exists _Items as Flag) is the semi-join exists _Items[Status = 'Open'], also when the association is one of the source only and when the filter comes in parentheses from a layer above. @remote.pushdown: false on the flag keeps it in memory.

Rules per hop:

  • The hop is joined on key pairs: the ON condition, managed foreign keys, or (EDMX associations without ON) the key names it shares with its target. Composite keys are pushed as (k1 = a and k2 = b) or ...; empty-string values are left out of the pushed filter (OData V2 backends answer X eq '' with no row) and the exact filter is applied in memory.
  • A constant on the target side of the ON condition (and _Partner.Role = 'BP') filters the target read; a constant on the parent side keeps the term in memory.
  • Supported terms: =, <, <=, >, >=, in, contains, startswith, endswith on the leaf, and exists / any() with infix filters (nested). Not ne (!=): CAP's != is two-valued, so a row without a target (or with a null value) matches it, and no key list selects such rows; it is evaluated in memory. To ask for a filled value, use gt '' instead of ne '' (a semi-join). A hop whose target is an external service (not a projection served by this application) only accepts a filter on a field of its own.
  • The target's key reads use the request's user, tenant and locale, so the target's @restrict applies.

A filter or sort through an association that cannot be a semi-join (for example or across associations) is evaluated in memory: the associations it reads are expanded with just the needed fields and removed from the answer again.


🔑 Reads by key

↑ Table of Contents

| Request | Backend call | | --- | --- | | Orders('O1') | Orders('O1') (key in the source's names); no $top, no count | | $filter=ID eq 'O1' (every source key pinned with =, or in with one value) | read by key, answer shaped back (list with $count, or SELECT.one) | | $filter=ID in ('O1','O2','O3') | one read by key per value (at most 50), merged, sorted, counted and paged here | | $filter=(Company eq 'C1' and No eq '1') or (Company eq 'C2' and No eq '7') | one read by key per key tuple | | a key tuple that contradicts another pinned key (Owner eq 'A' and ((Owner eq 'B' and ...) or ...)) | dropped: no row, no call | | an unknown key | 404 Entity '<name>' not found for a key read, an empty list for a filter; a backend that answers an unknown key with 400 instead of 404 is asked again with a filter, so the answer is the same |

A parenthesized group of ANDs counts like its terms (CAP adds a @restrict ... where as (Field = 'value')), so a key completed by such a term is still a read by key. $count=true on a single entity answers @odata.count: 1.

Backends answer keys in a $filter slowly (a scan) or wrongly; the key in the URL addresses the row. The rest of the WHERE is not sent (OData V2 services ignore a $filter on such a URL, some OData V4 services reject it) but evaluated on the returned row (also the projection's own where); a row that fails is 404 (key read) or left out.

When the entity's key is not the source's key (a DISTINCT view, a different key element), Entity('x') is read with a filter instead.

Navigation by key (Parent('k')/_Children)

GET Orders('O1')/_Items (a to-many or to-one association of a local entity, any number of hops) reads the parent by key, fills the association's on condition with its values and reads the target with that as a filter: one plain read of the target (Items where OrderID = 'O1'), with the request's $filter, $orderby, $top, $skip, $count. The on condition must be = terms joined by and (<assoc>.X = $self.Y, constants allowed); managed associations use their foreign keys; an association with neither (a navigation property imported from an OData service, redirected to a local entity) is joined on the key elements both entities share, as $expand does. An unknown parent key is 404 Entity '<parent>' not found; a parent without a value for the join is an empty list. Every segment may carry its own key or filter (Parent('k')/_Items(ItemID='i')/_Details(DetailID='d'); on a single-valued navigation it is a filter, as in CAP's path expressions): it narrows the rows of that hop together with the join. Not supported (501): a path without a key on the first segment, a parameterized segment, another on shape, more than 200 rows before the last hop.


🧠 Caching

↑ Table of Contents

Three opt-in caches, kept in the memory of each process (nothing is shared between instances; an entry lives at most its time to live). All are off until you switch them on; nothing changes for an entity you do not opt in.

Key sets of semi-joins. cds.query.remote.cache.semiJoins: true (or @remote.cache on the association's target or its service) keeps the key set a filter through an association reads (Filters through associations), per target, filter and tenant (and user with perUser). Every filter that needs the same key set then answers from memory, whatever else it filters on. With it on, the key set is always read (to be reused) instead of the in-memory evaluation of a narrow request: the first request after the time to live pays the full read, unless staleWhileRevalidate is set (below). Writes through the adapter to the target drop it.

Aggregates. cds.query.remote.cache.aggregates: true caches GROUP BY / DISTINCT entities (a single entity can opt out with @remote.cache: false, or opt in with @remote.cache: true). The whole group set of a filter is read once; $top, $skip, $orderby and $count are applied to a copy, so every page and order of that filter shares one entry. The key is the entity and the effective filter (it includes what CAP's @restrict ... where added), so users with different restrictions never share an entry; the columns are not part of it (the rows are cached complete and cut to the $select). Not for $search, $expand, SELECT.one, a read by key, @response.data or SOAP.

Rows by key. @remote.cache: true on an entity, or on its service for all its entities:

@remote.cache: true            // service level: every entity of the service
service ReferenceData { ... }

@remote.cache: { ttl: 60 }     // entity level, own time to live
entity Regions as projection on ...;

@remote.cache: false           // opt out of the service's setting
entity LiveSet as projection on ...;

A read of a remote entity by key (Entity('K'), $filter=Key eq 'K', key in ('a', 'b'), the key reads that resolve $expand and path columns) is answered from the cache while the row is fresh. For key in (a, b) each key is one read, so only the keys that are not cached go to the backend. The entry is the entity, the key and the columns read: another $select is another entry. Collection reads ($filter on other fields, paging, $search) are not cached. Only the hop that talks to the backend caches; a projection over another local service does not.

@remote.cache goes next to @remote on a service, it does not replace it: @remote: { cache: true } compiles to @remote.cache: true only, and the service is then not served by RemoteApplicationService (501 "cannot be served generically"; the plugin logs a warning). Write @remote and @remote.cache: true as two annotations.

Time to live (seconds, default 300): @remote.cache.ttl on the entity, else on the service, else cds.query.remote.cache.ttl, else 300.

Stale while revalidate (cds.query.remote.cache.staleWhileRevalidate: <seconds>, default off): an entry past its time to live is still answered for that many seconds, and one read in the background refreshes it. A request then never waits for an expensive read (a large key set, an aggregate over every row) once the entry exists; only the very first request, or one after the stale time too, reads the backend. A write through the adapter still drops the entry at once. A refresh that fails is logged on cds.log('remote') and the old entry is answered until its stale time ends.

Always: every answer is a copy; an error or an empty answer is not kept; identical reads that arrive while one is running wait for it (one backend read); a write, an action or a function through the adapter drops what is cached from the entity (an unbound operation clears the cache); a read that started before a write does not store its answer.

X-Cache response header: HIT; age=<seconds>, MISS, or PARTIAL; hits=<n>; misses=<m>; age=<seconds> when one HTTP response needed several lookups (the age is the oldest hit). Without a cached entity involved there is no header.

Limits: per process (with several instances each has its own entries and an entry can be stale for up to its time to live, also after a write that went through another instance); the data is as current as the last read. Set cds.query.remote.cache.perUser: true when the backend answers differently per user.

Warm-up (@remote.warmUp)

A cache helps from the second request on. @remote.warmUp makes the first one fast too: the entity is read when the server starts, and again just before the caches that read filled expire, so users find them filled (a semi-join's key set, an aggregate over every row).

// read once at start, then again before the filled caches expire
@remote.warmUp
entity ActiveCustomers as projection on ...;

// one read per filter the UI sends: each fills its own cache entry
@remote.warmUp: [
    { where: (HasOpenOrders = true) },
    { where: 'Country = ''DE''', every: 1800, limit: 10 }
]
entity Customers as projection on ...;

| Property | Meaning | Default | | --- | --- | --- | | where | Filter of the read: a CDS expression (...) (checked by the compiler) or a string in CDS expression syntax (parsed when the server starts; a string that does not parse is logged as an error and that read left out). | none | | every | Seconds between the end of one read and the next. | 90 % of the shortest time to live of the caches the read went through, less the time the read took. A read that went through no cache runs once (logged). | | limit | Rows read. A key set or an aggregate is cached whole, whatever the page. | 1 | | enabled | false switches the record (or @remote.warmUp.enabled: false the entity) off, e.g. in a profile. | true |

How it reads:

  • Through the entity's own service, as the privileged user, with every cache lookup of the read a miss: the caches are read and stored again, also while their entries are still fresh. Once per tenant of cds.query.remote.warmUp.tenants.
  • A cache entry is found only for the same effective query. The privileged user gets no @restrict ... where filter, so annotate the layer whose users get no user-dependent filter (the data layer, not the app service above it).
  • CDS copies the annotation to every projection above the entity. Only the lowest entity of a projection chain with an enabled warm-up is read; a projection above it would fill the same caches. An expression that names an element a projection above does not have stops the compiler ("Assign a value for @remote.warmUp.where"): write the where as a string then.
  • A failed read is logged as a warning and tried again after every, else after 300 seconds. Nothing is warmed with cds.query.remote.cache.perUser (no user would find the privileged user's entries). An annotation on an entity that no RemoteApplicationService serves is ignored with a warning.

When it runs (cds.query.remote.warmUp):

  • enabled: default on in Cloud Foundry (VCAP_APPLICATION) or with the production profile, off in tests and local runs.
  • Startup: the warm-up starts when the services are served. By default the server does not wait for it; a request that arrives during a warm-up read waits for that read instead of starting its own. With await: true (45 seconds) or await: <seconds> the server listens only after the first reads are done or that time is over (the reads go on in the background). Keep it below the platform's start-up health check timeout (Cloud Foundry: 60 seconds by default).
  • concurrency: reads at the same time at start (default 1). Every instance warms its own caches (they are per process).

✏️ Writing data

↑ Table of Contents

CREATE / UPDATE / DELETE on a simple projection are written to its source entity:

entity Orders as projection on RemoteOrders.Orders { key Company, key No, Note as Text, Header.Title as Title } where Kind = 'A';
  • POST Orders sends INSERT into RemoteOrders.Orders with Note, { Header: { Title } } and Kind = 'A' (from the where).
  • PATCH Orders(Company='C1',No='1') reads the row by key first (404 when it is outside the where), then sends PATCH Orders(Company='C1',No='1'). After an answer without a row (OData V2: 204) the entity is read again.
  • DELETE reads the row the same way, then deletes it.
  • Keys: the source's keys come from the URL key and req.data (local names). When the entity's key is not the source's key, the missing source keys are read by the entity's own key (a handler that knows them can put them into req.data and save that read).
  • Calculated, virtual and path elements are dropped from the data; an unknown element is 400; data for an association without @remote.write.deep is 501.
  • Database sources are written by CAP's generic handler. JOIN views, static data and SOAP sources are 501.
  • Backend errors keep their status (502 without one), code and message.
  • @remote.write.truncate on the entity or an element cuts strings to the element's length.

Handlers of your service run first: a before handler validates or sets values; an on handler registered before super.init() replaces the generic write unless it calls next().

Backend-only values

Values the backend needs but clients never send are projected as @readonly (often @UI.Hidden) elements and set by your handler. CAP drops client values of @readonly elements, not the values your handler puts into req.data.

Non-CRUD backends

For APIs where every change is a POST that names the operation in a field:

@remote.write.asInsert: ['UPDATE', 'DELETE']
@remote.write.operation: { field: 'Op', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }
entity Entries as projection on Remote.Entries { key Company, key Rec, Note, @readonly @UI.Hidden Released };

PATCH Entries(...) becomes POST Entries with { Note, Company, Rec, Op: 'U' }. A DELETE sent this way returns the backend's row when it answers one. Invalid settings (another event in asInsert, an operation field that is not an element of the source, codes without a field) are a 500 naming the entity.

Write rules

CAP checks @assert after the write, against the database, and rolls back on failure. A remote backend cannot roll back, so declare the rules with @remote.*; they are evaluated in memory before anything is written:

entity Bookings as projection on Remote.Bookings {
    key ID,
    Customer,
    @remote.assert.target
    _Customer : Association to one Customers on _Customer.ID = $self.Customer,       // ASSERT_TARGET on Customer
    Project,
    @remote.assert: (case when not exists _Project or _Project.Customer != Customer then 'ASSERT_TARGET' end)
    _Project : Association to one Projects on _Project.ID = $self.Project,
    @remote.assert: (case when Day < _Project.StartDate or Day > _Project.EndDate then 'BOOKING_OUTSIDE_PROJECT' end)
    @remote.assert.args: [Project, (left(_Project.StartDate, 10)), (left(_Project.EndDate, 10))]
    Day,
    @remote.write.truncate
    @remote.write.value: (Customer || ' - ' || _Customer.Name)
    CustomerText
};
  • @remote.assert: a CASE expression; the first matching when gives the message (text or i18n key, looked up like CAP's @assert), target = the element. @remote.assert.args gives {0}, {1}, ... (paths or (<expression>)).
  • @remote.assert.target on a to-one association: the target must exist (ASSERT_TARGET, target = its foreign key).
  • @remote.write.value: computed when the write's data has no value (a value from the caller or a handler wins); on UPDATE computed from the stored row merged with the data.
  • Expressions read the entity's elements and one-hop to-one paths (_Assoc.Field, exists _Assoc, not exists _Assoc), with CASE, ||, + - * /, comparisons, and / or / not, is [not] null and the in-memory functions. Each association is read once per write through the service that owns its target. Deeper or to-many paths are 501.
  • All failing checks are collected: one 400 error, or MULTIPLE_ERRORS with details.
  • A date compared with a date-time compares as instants.

Deep writes

entity Projects as projection on Remote.Projects {
    key ProjectID, Name,
    @remote.write.deep
    _Packages : Association to many Packages on _Packages.ProjectID = $self.ProjectID
};
entity Packages as projection on Remote.Packages { key ProjectID, @mandatory key PackageID, Description };
  • POST / PATCH Projects with _Packages: [...] writes the project, then every package row as an upsert (PATCH when a row with its key exists, else POST; a row without all its keys is created), the join keys filled in from the parent, recursively for the packages' own @remote.write.deep associations.
  • Checked before anything is written: the shape (to-many: an array of objects, to-one: an object; 400) and @mandatory of every child row (join keys from the parent excepted).
  • A failing row does not stop its siblings (its own children are skipped). All failures come back as one MULTIPLE_ERRORS (400 when all are 4xx, else 502) with the path of each row (_Packages[1]/_Items[0]). Rows written before stay written: there is no transaction across backend requests. Children left out of the payload are not deleted. No ETag handling.

⚙️ Actions and functions

↑ Table of Contents

Operations of the local service are forwarded to the source without a handler:

entity Documents as projection on Remote.Documents { key DocNo as No, Title } actions {
    action Cancel() returns many Remote.CancelResult;   // bound operation of the same name on Remote.Documents
    function GetPDF() returns Remote.PDF;
    @remote.operation: 'Release'
    action ReleaseDocument();                           // bound operation `Release` of Remote.Documents
};

@remote.operation: 'Remote.upsertStatus'               // unbound operation of another service
action setStatus(DocNo : String(10), Status : String(20)) returns Statuses;
  • Bound: the URL key is translated to the source's names and sent as the binding parameter (OData V2: a function import with the keys as parameters), the parameters as data.
  • Unbound: the parameters are sent as they are (their names must be the source operation's names).
  • returns X / returns many X with X an entity of this service over a remote source: rows mapped to local names (calculated columns included). Other answers are returned as they come.
  • A handler you register before super.init() wins. @remote.operation naming no existing operation fails init().
  • Backend errors keep their status (502 without one) and message.

🔌 Class API

↑ Table of Contents

import { RemoteApplicationService } from '@cap-ts/remote-service-adapter';

export class OrderService extends RemoteApplicationService {
    async init(): Promise<void> {
        this.after('CREATE', 'Entries', async (row, req) => {
            // a further write inside the same, already authorized request
            await this.writeSource('UPDATE', 'Entries', { ...row, Released: true }, { checkWhere: false });
        });
        await super.init();
    }
}

| Member | Description | | --- | --- | | init() | Registers READ, CREATE / UPDATE / DELETE and the operation forwarding for every entity, then super.init(). Called by CAP. | | readSource(entity, where, { columns }?) | One row through the read pipeline without the service's handlers (aliases, calculated columns, the projection's where; a filter on the source key is a read by key). where = element values compared with =. Returns the row in local names or undefined. | | writeSource(event, entity, data, { checkWhere, checks }?) | The generic write without the service's handlers and without CAP's authorization and input checks; @readonly values are kept. checkWhere: false skips the read of the row against the projection's where; checks: false skips the write rules. Only from a handler that has authorized the request. | | supportsSkipPagination | true: other instances may send this service the internal "all rows" hint of association fetches. |

entity is the definition or the name (with or without the service prefix); user, tenant, locale and headers come from cds.context. Both methods are 501 for an entity without a remote source.

Every request gets an 8-character correlation id that appears in every log line of that request.


🧩 CAP plugin: the @remote annotation

↑ Table of Contents

@remote
service Catalog {
    entity Partners as projection on ExternalPartners.Partners { key ID, Name as Title };
}
  • @remote on the local service is the entire opt-in. The plugin does not look at what the entities project on or at cds.requires; cds.requires.<name> only configures how the sources are reached.
  • A service keeps its own implementation when it has @impl, cds.requires.<service>.impl, or a handler file CAP would load (<name>.js / .mjs, .ts with TypeScript, next to the .cds, in lib/ or handlers/). To combine both, extend RemoteApplicationService in that file.
  • A service marked external (@cds.external, @external, cds.requires.<name>.external) is never patched.
  • Do not point cds.requires.<external>.impl at RemoteApplicationService: it reads through cds.connect.to(<external>), which would return itself.
  • DEBUG=remote-service logs which services were patched.

🧼 SOAP backends

↑ Table of Contents

{ 
  "cds": { 
    "requires": { 
      "Partners": { 
        "kind": "soap", 
        "wsdl": "srv/external/wsdl/Partners.wsdl", 
        "credentials": { 
          "destination": "PARTNERS_DESTINATION" 
        } 
      } 
    } 
  }
}

Annotate the external model as @cap-ts/soap-adapter documents. Reads go through soap.read (a fresh request per call, forbidden headers stripped), results are de-duplicated by the entity's keys, filter functions run in memory, $search matches every row here, case-insensitively (nothing is sent to the SOAP service). An association declared on the SOAP entity (extend <Soap>.Entity with { _X : Association to ... }) works for $expand, path columns and filters through it. SOAP sources are not writable, and a filter on the key is not turned into a read by key (the SOAP operation gets its parameters from the URL key or a flat and of equalities).


🛠️ Configuration and logging

↑ Table of Contents

Nothing has to be configured: the service is driven by cds.requires.<Service> and the annotations. Global runtime knobs live under cds.env.query.remote (the cds.query.remote key in package.json / .cdsrc.json, or an environment variable such as CDS_QUERY_REMOTE_PAGESINPARALLEL=3), next to CAP's own cds.query.limit. All are optional; the values shown are the defaults, except search.maxRows, which has none (5000 is an example):

{
  "cds": {
    "query": {
      "remote": {
        "pagesInParallel": 5,
        "paging": true,
        "capClientLog": false,
        "search": { "maxRows": 5000 },
        "semiJoin": { "maxRows": 5000, "pageSize": 1000 },
        "join": { "maxRows": 5000 },
        "cache": {