Using @cap-js/sqlite in CF for your CAP services
I published a couple of short posts recently:
- Running non-production CAP services in CF
- Easily add an explicit cds.requires.db to your CAP project's package.json
Both of them are related to going from zero to cloud, while still in design time, as quickly as possible. Partly to learn about the differences and similarities, but also simply because you can, and it elicits very interesting insights into how things work.
If you're going to take this approach of pushing a newly initialised CAP project to CF, and want to continue enjoying the default CAP server approach of using SQLite in-memory for the data layer, then you probably want to read those two blog posts, but also this one, because there's a fascinating combination of circumstances which means that unless you take steps to avoid it, you'll encounter an error when performing OData operations.
The error
While the CAP service in CF will start, and will serve you the landing page, the OData service document and even the OData metadata document, when it comes to serving actual entities (such as in response to a OData QUERY operation on a Books
entity set, for example), you'll get this:
<error xmlns="http://docs.oasis-open.org/odata/ns/metadata">
<code>500</code>
<message>No database connection</message>
<annotation term="Common.numericSeverity" type="Edm.Decimal">4</annotation>
</error>
Oops! Let's see why this happens, and what we can do to prevent it happening. We'll do that with a simple example very similar to the ones in the two posts mentioned earlier.
Setup
I love how I can get up and running with a basic but fully operational OData service with CAP in about 10 seconds. I'll do that now1, and immediately also ask (with tree
) for a quick overview of the files and directories created:
cds init --add tiny-sample qmacro-test \
&& cd $_ \
&& tree
The output looks something like this:
Creating new CAP project in ./qmacro-test
Adding feature 'nodejs'...
Adding feature 'tiny-sample'...
Successfully created project. Continue with 'cd qmacro-test'.
Find samples on https://github.com/SAP-samples/cloud-cap-samples
Learn about next steps at https://cap.cloud.sap
.
|-- README.md
|-- app
|-- db
| |-- data
| | `-- my.bookshop-Books.csv
| `-- data-model.cds
|-- package.json
`-- srv
`-- cat-service.cds
5 directories, 5 files
Lovely. Key for us here is what's in package.json
:
{
"name": "qmacro-test",
"version": "1.0.0",
"description": "A simple CAP project.",
"repository": "<Add your repository here>",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@sap/cds": "^7",
"express": "^4"
},
"devDependencies": {
"@cap-js/sqlite": "^1"
},
"scripts": {
"start": "cds-serve"
}
}
Dependencies vs devDependencies
Note the dependencies
and devDependencies
sections, to define package requirements in general, and package requirements for when we're in development, or "design time". In that latter section we have the SQLite package in the form of @cap-js/sqlite
. This is the right place for it, as it's very unlikely (apart from in the context of these experimental and throwaway services) that we'll want to use SQLite outside of the design time cycle.
We can already see this difference in the way that we can run the CAP server.
Design time with cds watch
Running it with cds watch
, a design time command, allows us to successfully request the books. Here's some of the log output from cds watch
:
[cds] - connect using bindings from: { registry: '~/.cds-services.json' }
[cds] - connect to db > sqlite { database: ':memory:' }
> init from db/data/my.bookshop-Books.csv
/> successfully deployed to in-memory database.
[cds] - serving CatalogService { path: '/odata/v4/catalog' }
[cds] - server listening on { url: 'http://localhost:4004' }
And when a request is made for the Books entity set we can see that the request is served successfully:
; curl -s localhost:4004/odata/v4/catalog/Books | jq .
{
"@odata.context": "$metadata#Books",
"value": [
{
"ID": 1,
"title": "Wuthering Heights",
"stock": 100
},
{
"ID": 2,
"title": "Jane Eyre",
"stock": 500
}
]
}
Runtime with cds serve
In contrast, cds serve
, a runtime (not design time) command, will not successfully respond to such a request. Here's the equivalent log output from cds serve
:
[cds] - loaded model from 2 file(s):
srv/cat-service.cds
db/data-model.cds
[cds] - serving CatalogService { path: '/odata/v4/catalog' }
[cds] - server listening on { url: 'http://localhost:4004' }
These following log lines, that we saw when we ran cds watch
, are conspicuous by their absence:
[cds] - connect to db > sqlite { database: ':memory:' }
> init from db/data/my.bookshop-Books.csv
/> successfully deployed to in-memory database.
Moreover, a similar OData QUERY made on the Books entity set results in an error:
; curl -s localhost:4004/odata/v4/catalog/Books | jq .
{
"error": {
"code": "500",
"message": "No database connection",
"@Common.numericSeverity": 4
}
}
The difference between the JSON output here, and the XML output shown earlier, is that the XML output earlier was from a web browser, which sends different values in the content negotiation part of the HTTP request made, compared to what is sent by curl2.
As well as the error being surfaced to the requester, we also have this output in the CAP server log:
[odata] - GET /odata/v4/catalog/Books
[cds] - Error: No database connection
at ApplicationService. (/usr/lib/node_modules/@sap/cds-dk/node_modules/@sap/cds/libx/_runtime/common/generic/crud.js:31:22)
at next (/usr/lib/node_modules/@sap/cds-dk/node_modules/@sap/cds/lib/srv/srv-dispatch.js:68:36)
at ApplicationService.handle (/usr/lib/node_modules/@sap/cds-dk/node_modules/@sap/cds/lib/srv/srv-dispatch.js:72:6)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async _readCollection (/usr/lib/node_modules/@sap/cds-dk/node_modules/@sap/cds/libx/_runtime/cds-services/adapter/odata-v4/handlers/read.js:246:19)
at async /usr/lib/node_modules/@sap/cds-dk/node_modules/@sap/cds/libx/_runtime/cds-services/adapter/odata-v4/handlers/read.js:538:16 {
numericSeverity: 4,
id: '1041709',
level: 'ERROR',
timestamp: 1713181867229
}
That's fine, I hear you say. Not unexpected! That's right. But it's also a clue as to the difference between design time and runtime.
Addressing the behaviour - locally
Under normal circumstances, this is correct behaviour, and doesn't need "fixing". But I find it fascinating to understand why things happen like this, and how they relate to other related areas.
So let's try to hack thing so they work in this runtime context.
Actually, all one needs to do is to get npm
to install the @cap-js/sqlite
package so that it appears in the runtime (dependencies
) section, rather than the design time (devDependencies
) section.
So, in the project's root, running:
npm install --save-prod @cap-js/sqlite
Use of the --save-prod option is crucial here.
will result in something very interesting - the @cap-js/sqlite
entry we saw earlier (in package.json
) moves from the devDependencies
section into the dependencies
section. What's more, the (now empty) devDependencies
section disappears!
{
"name": "qmacro-test",
"version": "1.0.0",
"description": "A simple CAP project.",
"repository": "<Add your repository here>",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@cap-js/sqlite": "^1.6.0",
"@sap/cds": "^7",
"express": "^4"
},
"scripts": {
"start": "cds-serve"
}
}
Now even cds serve
will use SQLite, emitting this in the log output on startup:
[cds] - connect to db > sqlite { database: ':memory:' }
> init from db/data/my.bookshop-Books.csv
/> successfully deployed to in-memory database.
and will response successfully to OData QUERY operations.
The behaviour - in CF
It turns out that exactly the same thing would happen if we push our CAP service, in its raw design time form, to the cloud. And it's for the same reason - the invocation of the CAP server in a deployment scenario like this is cds-serve
, as we can see from the package.json#scripts.start
property.
So performing the npm install --save-prod
before pushing to CF will in fact bring about the same effect.
Note that when pushing, you'll still need to set the NODE_ENV
to something other than production, using the approach described in Running non-production CAP services in CF, in other words:
cf push qmacro-test --no-start \
&& cf set-env qmacro-test NODE_ENV testing \
&& cf restage qmacro-test
which will bring about this in the log output:
Restaging app qmacro-test in org ...
...
-----> Creating runtime environment
NODE_ENV=testing
NODE_HOME=/tmp/contents2269148642/deps/0/node
NODE_MODULES_CACHE=true
NODE_VERBOSE=false
NPM_CONFIG_LOGLEVEL=error
NPM_CONFIG_PRODUCTION=true
npm scripts will see NODE_ENV=production (not 'testing')
https://docs.npmjs.com/misc/config#production
...
Restarting app qmacro-test in org ...
...
state since cpu memory disk logging details
#0 running 2024-04-15T12:33:54Z 0.0% 0B of 0B 0B of 0B 0B/s of 0B/s
Using NPM_CONFIG_PRODUCTION in CF
That's all well and good, but perhaps as hacks go, a little blunt. However, there's another way. As a result of digging into this in a slightly wider context, my focus was eventually directed to this part of the log output:
NODE_ENV=testing
NODE_HOME=/tmp/contents2269148642/deps/0/node
NODE_MODULES_CACHE=true
NODE_VERBOSE=false
NPM_CONFIG_LOGLEVEL=error
NPM_CONFIG_PRODUCTION=true
npm scripts will see NODE_ENV=production (not 'testing')
https://docs.npmjs.com/misc/config#production
First, NODE_ENV
is set as we want it, to testing
(i.e. something other than production
). This means that we can have our basic -- effectively still design time flavoured -- CAP server run fine in CF, without requiring production grade facilities.
But notice these two lines:
NPM_CONFIG_PRODUCTION=true
npm scripts will see NODE_ENV=production (not 'testing')
What's that?!
It turns out that there's a difference between NODE_ENV
and everything that implies (and consequently everything that behaves according to the value that is set for it), and how the Node.js package installer tool npm
sees the world in CF.
The value of NPM_CONFIG_PRODUCTION
affects how npm
behaves. Which in turn affects how the CAP project is set up once pushed to CF (because Node.js packages are installed in situ in CF, rather than transferred with the "push" operation, of course).
And if NPM_CONFIG_PRODUCTION
is set to true
, which it is by default here, then any packages listed in the package.json#devDependencies
are NOT installed.
So, for example, if I start from scratch, with:
cds init --add tiny-sample qmacro-test \
&& cd $_ \
and therefore have this in the project's package.json
again:
{
"dependencies": {
"@sap/cds": "^7",
"express": "^4"
},
"devDependencies": {
"@cap-js/sqlite": "^1"
}
}
Then I can achieve a successful push to, and execution within, CF, of my still "design time" CAP service, by also modifying the value for NPM_CONFIG_PRODUCTION
while I'm there modifying NODE_ENV
already.
Beginning with the push with the --no-start
option:
cf push qmacro-test --no-start
Then modifying the values of the two environment variables:
cf set-env qmacro-test NODE_ENV testing
cf set-env qmacro-test NPM_CONFIG_PRODUCTION false
While we're here, let's enable remote access to the app, like this (so we can verify things shortly):
cf enable-ssh qmacro-test
And finally the restage to start things up:
cf restage qmacro-test
And in the log output for the restage, we see lines like this:
Restaging app qmacro-test in org
...
NODE_ENV=testing
NPM_CONFIG_PRODUCTION=false
NODE_HOME=/tmp/contents28472526/deps/0/node
NODE_MODULES_CACHE=true
NODE_VERBOSE=false
NPM_CONFIG_LOGLEVEL=error
-----> Building dependencies
Installing node modules (package.json)
added 114 packages, and audited 115 packages in 4s
...
Restarting app qmacro-test in org
...
state since cpu memory disk logging details
#0 running 2024-04-15T13:41:30Z 0.0% 0B of 0B 0B of 0B 0B/s of 0B/s
And the best thing? Everything works as if it were still in design time. OData operations to retrieve data work as expected. And there's been absolutely nothing modified from the pure, just-initialised CAP project.
Trust but verify
Embracing that old adage trust, but verify, let's take a look. Just before starting the service up in CF, we enabled SSH access. So now we can use that to peek at what's there. First, let's satisfy ourselves that the contents of package.json
are as they were, i.e. unchanged from the project initialisation:
cf ssh qmacro-test -c "cat app/package.json" \
| jq '{ dependencies, devDependencies }'
This emits what we expect to see, with @cap-js/sqlite
still in the devDependencies
section:
{
"dependencies": {
"@sap/cds": "^7",
"express": "^4"
},
"devDependencies": {
"@cap-js/sqlite": "^1"
}
}
Plus, we can see what's been installed, for example like this:
cf ssh qmacro-test --command 'ls app/node_modules/@*'
This will emit:
app/node_modules/@cap-js:
cds-types
db-service
sqlite
app/node_modules/@sap:
cds
cds-compiler
cds-fiori
cds-foss
And there we can see that even in this CF environment, we have the @cap-js/sqlite
package installed. All because of the value of false
that we set for NPM_CONFIG_PRODUCTION
.
Good to know!
Footnotes
- The cds version I'm running right now is 7.8, as you can see from the output from
cds v
:
@cap-js/cds-types: 0.2.0
@sap/cds: 7.8.0
@sap/cds-compiler: 4.8.0
@sap/cds-dk (global): 7.8.1
@sap/cds-fiori: 1.2.3
@sap/cds-foss: 5.0.0
@sap/cds-mtxs: 1.17.0
@sap/eslint-plugin-cds: 2.6.7
Node.js: v20.11.1
home: /usr/lib/node_modules/@sap/cds-dk/node_modules/@sap/cds
- A key content negotiation header in HTTP requests is
Accept
, describing to the server what representation is desirable or acceptable for the resource being returned. Chrome sends this value (as one string, but split over lines here for readability):
text/html,
application/xhtml+xml,
application/xml;q=0.9,
image/avif,
image/webp,
image/apng,
*/*;q=0.8,
application/signed-exchange;v=b3;q=0.7
and curl sends this value:
*/*
by default. So the server (the CAP server, in this case) endeavours to comply to the more specific request from Chrome by supplying the OData entity set resource in XML, while it will be able to "relax" a little bit and just sent the default representation (JSON) in response to the request from curl.