A reCAP intro to the cds REPL
At reCAP, part of Code Connect 2025, I gave a talk on the cds REPL: "Gain a superpower by learning how to harness the cds REPL". You can watch the recording on the replay site; this post is a sort of summary and accompaniment, and an extension to my previous post on the topic. Read this post while watching the replay.
Setting up
Using my cap-con-img repo I built an image with the (at the time) latest CAP Node.js release which was 9.1.0 and launched a throwaway (--rm
) container from it (I actually used ./buildver latest
which will also work but today will give you a newer version of course):
; gh repo clone https://github.com/qmacro/cap-con-img \
&& cd cap-con-img \
&& ./buildbase \
&& ./buildver 9.1.0 \
&& docker run --rm -it cap-9.1.0 bash
node ➜ ~
$
For orientation, my local shell prompt is
;
, the shell prompt inside the container is$
and the cds REPL prompt will be>
.
Once at the container shell prompt I initialised a new CAP project based on the tiny-sample
facet, installing @sap-cloud-sdk/http-client
too, as I'll need that towards the end of the talk:
$ cds init --add tiny-sample tiny-sample \
&& cd tiny-sample \
&& npm add @sap-cloud-sdk/http-client
Here's what the entire project structure (minus the node_modules/
content) looks like:
$ tree -AI node*
.
├── README.md
├── app
├── db
│ ├── data
│ │ └── my.bookshop-Books.csv
│ └── schema.cds
├── eslint.config.mjs
├── package-lock.json
├── package.json
└── srv
└── cat-service.cds
Then I started the cds REPL:
$ cds repl
Welcome to cds repl v 9.1.0
>
It's also worth looking at the entire CDS model in source form, as it will provide the background for what I explore.
At the "db" layer there is db/schema.cds
:
namespace my.bookshop;
entity Books {
key ID : Integer;
title : String;
stock : Integer;
}
At the "service" layer there is srv/cat-service.cds
:
using my.bookshop as my from '../db/schema';
service CatalogService {
@readonly entity Books as projection on my.Books;
}
Getting help
The cds REPL is based on the Node.js REPL and asking for help shows the regular Node.js REPL commands plus .run
and .inspect
which are specific to the cds REPL:
> .help
.break Sometimes you get stuck, this gets you out
.clear Alias for .break
.editor Enter editor mode
.exit Exit the REPL
.help Print this help message
.inspect Sets options for util.inspect, e.g. `.inspect .depth=1`.
.load Load JS from a file into the REPL session
.run Runs a cds server from a given CAP project folder, or module name like @capire/bookshop.
.save Save all evaluated commands in this REPL session to a file
Press Ctrl+C to abort current expression, Ctrl+D to exit the REPL
Exploring the cds facade with .inspect
The cds facade is a good place to start exploring. It contains a lot of detail, so I used the .inspect
feature to keep things to a minimum:
> .inspect .depth=0 cds
cds: cds_facade {
_events: [Object: null prototype],
_eventsCount: 2,
_maxListeners: undefined,
model: undefined,
db: undefined,
cli: [Object],
root: '/home/node/tiny-sample',
services: {},
extend: [Function (anonymous)],
version: '9.1.0',
builtin: [Object],
service: [Function],
log: [Function],
parse: [Function],
home: '/home/node/tiny-sample/node_modules/@sap/cds',
env: [Config],
requires: {},
[Symbol(shapeMode)]: false,
[Symbol(kCapture)]: false
}
Running .inspect .depth=N
on its own will fix the detail level for subsequent inspections to N.
> .inspect .depth=0
updated node:util.inspect.defaultOptions with: { depth: 0 }
At this point there are no values for db
, services
or model
in the facade, because I've not yet started any CAP server.
Starting a server with .run
With .run
I started a CAP server based on the project in the current (.
) directory:
> .run .
... (usual CAP server output)
Following variables are made available in your repl's global context:
from cds.entities: {
Books,
}
from cds.services: {
db,
CatalogService,
}
Simply type e.g. CatalogService in the prompt to use the respective objects.
This can be done on launching the REPL too like this:
cds repl --run .
, orcds r -r .
if you like short invocations.
Everything is a service, including the database facility (db
) as well as the CatalogService
defined in srv/cat-service.cds
.
And there's also the Books
entity from db/schema.cds
(at this point I've increased the .inspect
depth from 0 to 4):
> Books
entity {
kind: 'entity',
elements: LinkedDefinitions {
ID: Integer { key: true, type: 'cds.Integer' },
title: String { type: 'cds.String' },
stock: Integer { type: 'cds.Integer' }
}
}
I then took a peek at some of the detail in the CatalogService
, specifically the handlers
, to show the built-in mechanisms - all those features one can read about in Capire such as auth checks, autoexposure, input validation, paging, sorting, and so on, plus the complete support for CRUD operations, is there:
> CatalogService.handlers
EventHandlers {
_initial: [
{
before: '*',
handler: [Function: check_service_level_restrictions]
},
{ before: '*', handler: [Function: check_auth_privileges] },
{ before: '*', handler: [Function: check_readonly] },
{ before: '*', handler: [Function: check_insertonly] },
{ before: '*', handler: [Function: check_odata_constraints] },
{ before: '*', handler: [Function: check_autoexposed] },
{ before: '*', handler: [AsyncFunction: enforce_auth] },
{ before: 'READ', handler: [Function: restrict_expand] },
{ before: 'CREATE', handler: [AsyncFunction: validate_input] },
{ before: 'UPDATE', handler: [AsyncFunction: validate_input] },
{ before: 'NEW', handler: [AsyncFunction: validate_input] },
{ before: 'READ', handler: [Function: handle_paging] },
{ before: 'READ', handler: [Function: handle_sorting] }
],
before: [],
on: [
{ on: 'CREATE', handler: [AsyncFunction: handle_crud_requests] },
{ on: 'READ', handler: [AsyncFunction: handle_crud_requests] },
{ on: 'UPDATE', handler: [AsyncFunction: handle_crud_requests] },
{ on: 'DELETE', handler: [AsyncFunction: handle_crud_requests] },
{ on: 'UPSERT', handler: [AsyncFunction: handle_crud_requests] }
],
after: [],
_error: []
}
Understanding entities at different levels
With the CDS model in mind, I stopped for a moment to look at the difference between what the injected variable Books
represents (shown just above), and what the expanded "service-equivalent" looks like, in CatalogService.entities
, which I first extracted using a destructuring assignment:
> { Books: mybooks } = CatalogService.entities
[object Function]
and then inspected:
> mybooks
entity {
kind: 'entity',
'@readonly': true,
projection: { from: { ref: [ 'my.bookshop.Books' ] } },
elements: LinkedDefinitions {
ID: Integer { key: true, type: 'cds.Integer' },
title: String { type: 'cds.String' },
stock: Integer { type: 'cds.Integer' }
},
'@Capabilities.DeleteRestrictions.Deletable': false,
'@Capabilities.InsertRestrictions.Insertable': false,
'@Capabilities.UpdateRestrictions.Updatable': false
}
What's different is that this "version" is a projection on the Books
entity (in the my.bookshop
namespace), defined by a query object construct, there's a @readonly
annotation defined upon it, which in turn expand into the @Capabilities
based restrictions seen here.
Building and executing query objects
In CAP query objects are first class citizens and essential to our understanding of the fundamentals. They're constructed at a core level with cds.ql but there are also higher level APIs such as the CRUD-style API which I used here, assigning the object directly to a variable:
> thebooks = CatalogService.read(mybooks)
cds.ql {
SELECT: { from: { ref: [ 'CatalogService.Books' ] } }
}
To execute the query I used await
, which by default passes the query to cds.db.run()
:
> await thebooks
[
{ ID: 1, title: 'Wuthering Heights', stock: 100 },
{ ID: 2, title: 'Jane Eyre', stock: 500 }
]
In other words, this is the same as:
> await db.run(thebooks)
because I still have the db
variable that was automatically made available in the REPL session's global context.
Queries can be extended, which I did at this point:
> await thebooks.where({'stock': {'>': 100}})
[ { ID: 2, title: 'Jane Eyre', stock: 500 } ]
Beware, this will modify the query object:
> thebooks
cds.ql {
SELECT: {
from: { ref: [ 'CatalogService.Books' ] },
where: [ { ref: [ 'stock' ] }, '>', { val: 100 } ]
}
}
Creating a service from scratch
As well as defining a service (such as CatalogService
) in the CDS model, it's possible to create a service from the ground up, which I illustrated next:
> srv = new cds.Service
Service {
name: 'Service',
options: {},
handlers: EventHandlers {
_initial: [],
before: [],
on: [],
after: [],
_error: []
},
definition: undefined
}
This is like an "empty" version of the service I looked at before - for example, there are no handlers defined.
That doesn't prevent the sending of messages; it's just that nothing will happen, as I then demonstrated:
> await srv.send('recap', { is: "awesome" })
>
At this point I defined a super simple handler for the recap
event (remember, a handler definition is essentially a function, so console.log
will do just fine here):
> srv.on('recap', console.log)
This time, because of the definition of the on
phase handler for this event named recap
, the data is passed to the handler function, which outputs it:
> await srv.send('recap', { is: "awesome" })
Request { method: 'recap', data: { is: 'awesome' } } [AsyncFunction: next]
This is what was shown in the output:
- what is being passed is an object of type
Request
- the content of the
Request
, an object containing the requestmethod
, and the payload (indata
) - a
next
function that can be called by the handler
In other words, this is effectively a request/response concept, close to the synchronous ideas in HTTP, for example.
Then, to contrast this with an event message concept, close to the ideas in the asynchronous ideas in event emitters and receivers, I swapped out the srv.send
and used srv.emit
instead:
> await srv.emit('recap', { is: "awesome" })
EventMessage { event: 'recap', data: { is: 'awesome' } } [Function: _dummy]
This time the console.log
handler showed that:
- what is being passed is an object of type
EventMessage
(as opposed toRequest
) - the
recap
name is the same, but is now treated as anevent
rather than amethod
- there is no
next
function (just a_dummy
)
This next
vs _dummy
function is at the heart of one of the key differences between events and requests in CAP. In both contexts one can define multiple handlers that are called in sequence for a given message.
The handling of requests is done in the context of a classic interceptor stack, where any given handler break the chain and effectively declare that the message has been handled (by not calling next
to pass the processing to the next handler in the stack). But there is no interceptor stack in the handling of events - every registered handler is called, regardless (and therefore there's no need for the next
function).
Sending queries to a remote service
Constructing and sending queries to services is fundamental in CAP. Earlier I constructed a query and sent it to the db
service. In this last part of the talk I showed how one can construct a query and send it to a remote service (my Northbreeze OData service), without having to think too much at all about the fact that it is even remote.
In CAP services are either "required" or "provided". A remote service is one that is "required", commonly with the details in package.json#cds.requires
, and cds.connect is used to make a connection to such a remote service.
I don't have anything in package.json#cds.requires
so this is what I got when I tried something simple like this:
> cds.connect.to('northbreeze')
Promise {
<rejected> Error: Didn't find a configuration for 'cds.requires.northbreeze' in /home/node/tiny-sample
There's a second parameter to cds.connect.to
which expects an options object, wherein one can provide a service binding (essentially a destination object) in a credentials
property:
> nb = await cds.connect.to('northbreeze', {kind: 'odata', credentials: { url: 'https://developer-challenge.cfapps.eu10.hana.ondemand.com/odata/v4/northbreeze' }})
RemoteService {
name: 'northbreeze',
options: {
kind: 'odata',
impl: '@sap/cds/libx/_runtime/remote/Service.js',
external: true,
credentials: {
url: 'https://developer-challenge.cfapps.eu10.hana.ondemand.com/odata/v4/northbreeze'
}
},
kind: 'odata',
handlers: EventHandlers {
_initial: [ { before: 'UPDATE', handler: [Function: clearKeysFromData] } ],
before: [],
on: [ { on: '*', handler: [AsyncFunction: on_handler] } ],
after: [],
_error: []
},
definition: undefined,
_source: '/home/node/tiny-sample/node_modules/@sap/cds/libx/_runtime/remote/Service.js',
datasource: undefined,
destinationOptions: undefined,
destination: {
name: undefined,
url: 'https://developer-challenge.cfapps.eu10.hana.ondemand.com/odata/v4/northbreeze'
},
path: undefined,
requestTimeout: 60000,
csrf: undefined,
csrfInBatch: undefined,
middlewares: { timeout: [Function (anonymous)], csrf: undefined },
entities: [],
actions: []
}
Doing this, and assigning the result to a variable, gives a RemoteService
object, which is on a similar level to the db
and CatalogService
objects I had after starting the CAP server:
> [db, CatalogService, nb].forEach(x => console.log(`${x.name}: ${x.constructor.name} (${x.kind})`))
db: SQLiteService (sqlite)
CatalogService: ApplicationService (app-service)
northbreeze: RemoteService (odata)
At this point I constructed a new query object, enjoying how the approach (which uses tagged templates) allows me to express my query in almost-English:
> cats = SELECT `CategoryName` .from `Categories`
cds.ql {
SELECT: {
from: { ref: [ 'Categories' ] },
columns: [ { ref: [ 'CategoryName' ] } ]
}
}
The context here in the post has diverged slightly from the context of the talk, because (as I'd exited the cds REPL back to the shell to show the output of npm ls
) the cds REPL session at this point was a fresh one in the talk, one where I hadn't re-invoked the CAP server (with .run .
), and therefore there was no db
variable injected into the global REPL context like before.
So when I ran this during the talk, this happened, which nicely illustrated the default use of db.run
when await
-ing a query:
> await cats
Uncaught Error: Can't execute query as no primary database is connected.
But here, while we do have a primary database in the form of db
, we get a different and equally illustrative error:
> await cats
Uncaught SqliteError: no such table: Categories in:
SELECT CategoryName FROM Categories
Of course, I wanted to have this query sent to the remote service, which I achieved with the same method but called on the RemoteService
object (in nb
), rather than the SQLiteService
object (in db
):
> await nb.run(cats)
[
{ CategoryName: 'Beverages', CategoryID: 1 },
{ CategoryName: 'Condiments', CategoryID: 2 },
{ CategoryName: 'Confections', CategoryID: 3 },
{ CategoryName: 'Dairy Products', CategoryID: 4 },
{ CategoryName: 'Grains/Cereals', CategoryID: 5 },
{ CategoryName: 'Meat/Poultry', CategoryID: 6 },
{ CategoryName: 'Produce', CategoryID: 7 },
{ CategoryName: 'Seafood', CategoryID: 8 }
]
The query object is serialised and sent to the remote service, and the results returned, without me having to do anything! If you're curious, information on the built-in implementation that facilitates this is also available:
> nb.options
{
kind: 'odata',
impl: '@sap/cds/libx/_runtime/remote/Service.js',
external: true,
credentials: {
url: 'https://developer-challenge.cfapps.eu10.hana.ondemand.com/odata/v4/northbreeze'
}
}
Excellent!
Wrapping up
With that, I wrapped up the talk (it was only a 20 minute slot) and so I'll wrap up this post too.
Let me know in the comments how you get on with the cds REPL, and share what you discover!