@cap-js-community/common 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## Version 0.3.1 - 2025-11-05
9
+
10
+ ### Fixed
11
+
12
+ - Rework redis client
13
+
14
+ ## Version 0.3.0 - 2025-11-03
15
+
16
+ ### Fixed
17
+
18
+ - Refactor replication cache
19
+ - Change `@cap-js/sqlite` to dev dependency
20
+ - Replication cache requires `@cap-js/sqlite` as project dependency (no dev dependency)
21
+
8
22
  ## Version 0.2.8 - 2025-10-13
9
23
 
10
24
  ### Fixed
package/README.md CHANGED
@@ -30,6 +30,9 @@ Local replicated SQLite database can be queried with same query as the original
30
30
 
31
31
  ### Usage
32
32
 
33
+ > Replication cache uses SQLite as local database for productive usage.
34
+ > Ensure `@cap-js/sqlite` is installed as dependency (not as dev dependency) in your project.
35
+
33
36
  ```cds
34
37
  @cds.replicate
35
38
  entity Books {
@@ -325,14 +328,14 @@ A Redis Client broker is provided to connect to Redis service.
325
328
 
326
329
  ```js
327
330
  const { RedisClient } = require("@cap-js-community/common");
328
- const mainClient = await RedisClient.default().createMainClientAndConnect(options);
331
+ const mainClient = await RedisClient.create().createMainClientAndConnect(options);
329
332
  ```
330
333
 
331
334
  #### Main named singleton
332
335
 
333
336
  ```js
334
337
  const { RedisClient } = require("@cap-js-community/common");
335
- const mainClient = await RedisClient.default("name").createMainClientAndConnect(options);
338
+ const mainClient = await RedisClient.create("name").createMainClientAndConnect(options);
336
339
  ```
337
340
 
338
341
  #### Custom named
@@ -381,14 +384,14 @@ Specific Redis options for a custom name can be established as follows:
381
384
 
382
385
  ```js
383
386
  const { RedisClient } = require("@cap-js-community/common");
384
- const mainClient = await RedisClient.default("customName").createMainClientAndConnect(options);
387
+ const mainClient = await RedisClient.create("customName").createMainClientAndConnect(options);
385
388
  ```
386
389
 
387
390
  In addition, options can be passed to Redis client during creation via `options` parameter:
388
391
 
389
392
  ```js
390
393
  const { RedisClient } = require("@cap-js-community/common");
391
- const mainClient = await RedisClient.default().createMainClientAndConnect(options);
394
+ const mainClient = await RedisClient.create().createMainClientAndConnect(options);
392
395
  ```
393
396
 
394
397
  For details on Redis `createClient` configuration options see [Redis Client Configuration](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md).
package/cds-plugin.js CHANGED
@@ -3,9 +3,7 @@
3
3
  const cds = require("@sap/cds");
4
4
  require("./src/common/promise");
5
5
 
6
- const { ReplicationCache, RateLimiting, RedisClient } = require("./src");
7
-
8
- const TIMEOUT_SHUTDOWN = 2500;
6
+ const { ReplicationCache, RateLimiting } = require("./src");
9
7
 
10
8
  if (cds.env.rateLimiting.plugin) {
11
9
  cds.on("serving", async (service) => {
@@ -24,25 +22,3 @@ if (cds.env.rateLimiting.plugin) {
24
22
  if (cds.env.replicationCache.plugin) {
25
23
  cds.replicationCache = new ReplicationCache();
26
24
  }
27
-
28
- cds.on("shutdown", async () => {
29
- await shutdownWebSocketServer();
30
- });
31
-
32
- async function shutdownWebSocketServer() {
33
- return await new Promise((resolve, reject) => {
34
- const timeoutRef = setTimeout(() => {
35
- clearTimeout(timeoutRef);
36
- resolve();
37
- }, TIMEOUT_SHUTDOWN);
38
- RedisClient.closeAllClients()
39
- .then((result) => {
40
- clearTimeout(timeoutRef);
41
- resolve(result);
42
- })
43
- .catch((err) => {
44
- clearTimeout(timeoutRef);
45
- reject(err);
46
- });
47
- });
48
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/common",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "CAP Node.js Community Common",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "engines": {
@@ -45,7 +45,6 @@
45
45
  "audit": "npm audit --only=prod"
46
46
  },
47
47
  "dependencies": {
48
- "@cap-js/sqlite": "^2.0.3",
49
48
  "commander": "^14.0.1",
50
49
  "redis": "^4.7.1",
51
50
  "verror": "^1.10.1"
@@ -53,10 +52,11 @@
53
52
  "devDependencies": {
54
53
  "@cap-js-community/common": "./",
55
54
  "@cap-js/cds-test": "^0.4.0",
55
+ "@cap-js/sqlite": "^2.0.3",
56
56
  "@sap/cds": "^9.4.3",
57
57
  "@sap/cds-common-content": "^3.0.1",
58
58
  "@sap/cds-dk": "^9.4.1",
59
- "eslint": "^9.36.0",
59
+ "eslint": "^9.39.1",
60
60
  "eslint-config-prettier": "^10.1.8",
61
61
  "eslint-plugin-jest": "^29.0.1",
62
62
  "eslint-plugin-n": "^17.23.1",
@@ -5,7 +5,7 @@ const path = require("path");
5
5
  const fs = require("fs");
6
6
  const crypto = require("crypto");
7
7
 
8
- const COMPONENT_NAME = "migrationCheck";
8
+ const COMPONENT_NAME = "/cap-js-community-common/migrationCheck";
9
9
  const STRING_DEFAULT_LENGTH = 5000;
10
10
 
11
11
  const Checks = [releasedEntityCheck, newEntityCheck, uniqueIndexCheck, journalModeCheck];
@@ -7,7 +7,7 @@ const redisResetTime = require("./redis/resetTime");
7
7
 
8
8
  const { connectionCheck } = require("./redis/common");
9
9
 
10
- const COMPONENT_NAME = "rateLimiting";
10
+ const COMPONENT_NAME = "/cap-js-community-common/rateLimiting";
11
11
 
12
12
  class RateLimiting {
13
13
  constructor(service, { maxConcurrent, maxInWindow, window } = {}) {
@@ -4,14 +4,14 @@ const cds = require("@sap/cds");
4
4
 
5
5
  const { RedisClient } = require("../../redis-client");
6
6
 
7
- const COMPONENT_NAME = "rateLimiting";
7
+ const COMPONENT_NAME = "/cap-js-community-common/rateLimiting";
8
8
 
9
9
  async function connectionCheck() {
10
- return await RedisClient.default(COMPONENT_NAME).connectionCheck();
10
+ return await RedisClient.create(COMPONENT_NAME).connectionCheck();
11
11
  }
12
12
 
13
13
  async function perform(key, cb, cbFallback, retry = cds.env.rateLimiting.retry) {
14
- const client = cds.env.rateLimiting.redis && (await RedisClient.default(COMPONENT_NAME).createMainClientAndConnect());
14
+ const client = cds.env.rateLimiting.redis && (await RedisClient.create(COMPONENT_NAME).createMainClientAndConnect());
15
15
  if (client) {
16
16
  const value = await cb(client, key);
17
17
  if (value === undefined) {
@@ -1,19 +1,29 @@
1
1
  "use strict";
2
2
 
3
3
  const redis = require("redis");
4
+ const cds = require("@sap/cds");
4
5
 
5
- const COMPONENT_NAME = "redisClient";
6
+ const COMPONENT_NAME = "/cap-js-community-common/redisClient";
6
7
  const LOG_AFTER_SEC = 5;
8
+ const TIMEOUT_SHUTDOWN = 2500;
7
9
 
8
10
  class RedisClient {
11
+ #clusterClient = false;
12
+ #beforeCloseHandler;
9
13
  constructor(name) {
10
14
  this.name = name;
11
15
  this.log = cds.log(COMPONENT_NAME);
12
16
  this.mainClientPromise = null;
13
- this.additionalClientPromise = null;
14
17
  this.subscriberClientPromise = null;
15
18
  this.subscribedChannels = {};
16
19
  this.lastErrorLog = Date.now();
20
+
21
+ if (!RedisClient._shutdownRegistered) {
22
+ RedisClient._shutdownRegistered = true;
23
+ cds.on("shutdown", async () => {
24
+ await this.closeRedisClients();
25
+ });
26
+ }
17
27
  }
18
28
 
19
29
  createMainClientAndConnect(options) {
@@ -32,22 +42,6 @@ class RedisClient {
32
42
  return this.mainClientPromise;
33
43
  }
34
44
 
35
- createAdditionalClientAndConnect(options) {
36
- if (this.additionalClientPromise) {
37
- return this.additionalClientPromise;
38
- }
39
-
40
- const errorHandlerCreateClient = (err) => {
41
- this.additionalClientPromise?.then?.(this.resilientClientClose);
42
- this.log.error("Error from additional redis client", err);
43
- this.additionalClientPromise = null;
44
- setTimeout(() => this.createAdditionalClientAndConnect(options), LOG_AFTER_SEC * 1000).unref();
45
- };
46
-
47
- this.additionalClientPromise = this.createClientAndConnect(options, errorHandlerCreateClient);
48
- return this.additionalClientPromise;
49
- }
50
-
51
45
  async createClientAndConnect(options, errorHandlerCreateClient, isConnectionCheck) {
52
46
  try {
53
47
  const client = this.createClientBase(options);
@@ -92,7 +86,8 @@ class RedisClient {
92
86
  throw error;
93
87
  }
94
88
  if (client) {
95
- await this.resilientClientClose(client);
89
+ // NOTE: ignore promise: client should not wait + fn can't throw
90
+ this.resilientClientClose(client);
96
91
  return true;
97
92
  }
98
93
  } catch (err) {
@@ -119,6 +114,7 @@ class RedisClient {
119
114
  };
120
115
  try {
121
116
  if (credentials?.cluster_mode) {
117
+ this.#clusterClient = true;
122
118
  return redis.createCluster({
123
119
  rootNodes: [socketOptions],
124
120
  defaults: socketOptions,
@@ -141,14 +137,14 @@ class RedisClient {
141
137
  LOG_AFTER_SEC * 1000,
142
138
  ).unref();
143
139
  };
144
- this.subscribeChannels(options, { [channel]: subscribeHandler }, errorHandlerCreateClient);
140
+ this.subscribeChannels(options, errorHandlerCreateClient);
145
141
  }
146
142
 
147
- subscribeChannels(options, subscribedChannels, errorHandlerCreateClient) {
143
+ subscribeChannels(options, errorHandlerCreateClient) {
148
144
  this.subscriberClientPromise = this.createClientAndConnect(options, errorHandlerCreateClient)
149
145
  .then((client) => {
150
146
  for (const channel in this.subscribedChannels) {
151
- const fn = subscribedChannels[channel];
147
+ const fn = this.subscribedChannels[channel];
152
148
  client._subscribedChannels ??= {};
153
149
  if (client._subscribedChannels[channel]) {
154
150
  continue;
@@ -193,16 +189,6 @@ class RedisClient {
193
189
  this.log.info("Main redis client closed!");
194
190
  }
195
191
 
196
- async closeAdditionalClient() {
197
- if (!this.additionalClientPromise) {
198
- return;
199
- }
200
- const client = this.additionalClientPromise;
201
- this.additionalClientPromise = null;
202
- await this.resilientClientClose(await client);
203
- this.log.info("Additional redis client closed!");
204
- }
205
-
206
192
  async closeSubscribeClient() {
207
193
  if (!this.subscriberClientPromise) {
208
194
  return;
@@ -214,9 +200,10 @@ class RedisClient {
214
200
  }
215
201
 
216
202
  async closeClients() {
217
- await this.closeMainClient();
218
- await this.closeAdditionalClient();
219
- await this.closeSubscribeClient();
203
+ if (this.#beforeCloseHandler) {
204
+ await this.#beforeCloseHandler();
205
+ }
206
+ await Promise.allSettled([this.closeMainClient(), this.closeSubscribeClient()]);
220
207
  }
221
208
 
222
209
  async resilientClientClose(client) {
@@ -229,16 +216,42 @@ class RedisClient {
229
216
  }
230
217
  }
231
218
 
232
- static default(name = "default") {
233
- RedisClient._default ??= {};
234
- if (!RedisClient._default[name]) {
235
- RedisClient._default[name] = new RedisClient(name);
219
+ async closeRedisClients() {
220
+ return await new Promise((resolve, reject) => {
221
+ const timeoutRef = setTimeout(() => {
222
+ clearTimeout(timeoutRef);
223
+ resolve();
224
+ }, TIMEOUT_SHUTDOWN);
225
+ RedisClient.closeAllClients()
226
+ .then((result) => {
227
+ clearTimeout(timeoutRef);
228
+ resolve(result);
229
+ })
230
+ .catch((err) => {
231
+ clearTimeout(timeoutRef);
232
+ reject(err);
233
+ });
234
+ });
235
+ }
236
+
237
+ set beforeCloseHandler(cb) {
238
+ this.#beforeCloseHandler = cb;
239
+ }
240
+
241
+ get isCluster() {
242
+ return this.#clusterClient;
243
+ }
244
+
245
+ static create(name = "default") {
246
+ RedisClient._create ??= {};
247
+ if (!RedisClient._create[name]) {
248
+ RedisClient._create[name] = new RedisClient(name);
236
249
  }
237
- return RedisClient._default[name];
250
+ return RedisClient._create[name];
238
251
  }
239
252
 
240
253
  static async closeAllClients() {
241
- for (const entry of Object.values(RedisClient._default || {})) {
254
+ for (const entry of Object.values(RedisClient._create || {})) {
242
255
  await entry.closeClients();
243
256
  }
244
257
  }
@@ -5,8 +5,6 @@ const path = require("path");
5
5
  const fs = require("fs").promises;
6
6
 
7
7
  const cds = require("@sap/cds");
8
- const SQLiteService = require("@cap-js/sqlite");
9
-
10
8
  require("../common/promise");
11
9
 
12
10
  const Component = "replicationCache";
@@ -51,7 +49,7 @@ class ReplicationCache {
51
49
  this.group = this.options.group;
52
50
  this.log = cds.log(Component);
53
51
  this.template = null;
54
- this.cache = new Map();
52
+ this.entries = new Map();
55
53
  this.initStats();
56
54
  this.attach();
57
55
  }
@@ -64,6 +62,8 @@ class ReplicationCache {
64
62
  if (service.name === this.name) {
65
63
  const refs = ReplicationCache.replicationRefs(this.model, service, this.options.deploy);
66
64
  if (refs.length > 0) {
65
+ // @cap-js/sqlite dependency is required for replication cache
66
+ this.SQLiteService = require("@cap-js/sqlite");
67
67
  this.setup(service, refs);
68
68
  this.log.info("using replication cache", {
69
69
  service: service.name,
@@ -74,7 +74,7 @@ class ReplicationCache {
74
74
  this.options?.credentials?.database !== Constants.InMemory
75
75
  ) {
76
76
  this.log.info("Preparing replication cache template database");
77
- this.template = createDB(Tenant.Template, this.model, this.options)
77
+ this.template = createDB(this.SQLiteService, Tenant.Template, this.model, this.options)
78
78
  .then(() => {
79
79
  this.log.info("Prepared replication cache template database");
80
80
  })
@@ -220,7 +220,7 @@ class ReplicationCache {
220
220
  this.stats.used++;
221
221
  this.stats.ratio = Math.round(this.stats.used / this.stats.hits);
222
222
  this.log.debug("Replication cache was used");
223
- const db = this.cache.get(tenant).db;
223
+ const db = this.entries.get(tenant).db;
224
224
  if (this.options.measure) {
225
225
  return this.measure(
226
226
  async () => {
@@ -316,15 +316,15 @@ class ReplicationCache {
316
316
  if (refs.length === 0) {
317
317
  return;
318
318
  }
319
- let tenantCache = cached(this.cache, tenant, async () => {
320
- return new ReplicationCacheTenant(tenant, model, this.options).prepare();
319
+ let tenantCache = cached(this.entries, tenant, async () => {
320
+ return new ReplicationCacheTenant(this, tenant, model, this.options).prepare();
321
321
  });
322
322
  return (async () => {
323
323
  try {
324
324
  const prepared = Promise.resolve(tenantCache).then(async (tenantCache) => {
325
325
  const prepares = [];
326
326
  for (const ref of refs) {
327
- const entry = cached(tenantCache.cache, ref, () => {
327
+ const entry = cached(tenantCache.entries, ref, () => {
328
328
  return new ReplicationCacheEntry(this, tenantCache, ref);
329
329
  });
330
330
  entry.touched = Date.now();
@@ -346,7 +346,7 @@ class ReplicationCache {
346
346
  return Status.NotReady;
347
347
  }
348
348
  for (const ref of refs) {
349
- const entry = tenantCache.cache.get(ref);
349
+ const entry = tenantCache.entries.get(ref);
350
350
  if (!entry || entry.status !== Status.Ready) {
351
351
  return Status.NotReady;
352
352
  }
@@ -361,13 +361,13 @@ class ReplicationCache {
361
361
  }
362
362
 
363
363
  async prepared(tenant, ref) {
364
- const tenants = tenant ? [tenant] : this.cache.keys();
364
+ const tenants = tenant ? [tenant] : this.entries.keys();
365
365
  for (const id of tenants) {
366
- const tenant = await this.cache.get(id);
366
+ const tenant = await this.entries.get(id);
367
367
  if (tenant) {
368
- const refs = ref ? [ref] : tenant.cache.keys();
368
+ const refs = ref ? [ref] : tenant.entries.keys();
369
369
  for (const ref of refs) {
370
- const entry = tenant.cache.get(ref);
370
+ const entry = tenant.entries.get(ref);
371
371
  if (entry) {
372
372
  await entry.prepared;
373
373
  }
@@ -377,13 +377,13 @@ class ReplicationCache {
377
377
  }
378
378
 
379
379
  async clear(tenant, ref) {
380
- const tenants = tenant ? [tenant] : this.cache.keys();
380
+ const tenants = tenant ? [tenant] : this.entries.keys();
381
381
  for (const id of tenants) {
382
- const tenant = await this.cache.get(id);
382
+ const tenant = await this.entries.get(id);
383
383
  if (tenant) {
384
- const refs = ref ? [ref] : tenant.cache.keys();
384
+ const refs = ref ? [ref] : tenant.entries.keys();
385
385
  for (const ref of refs) {
386
- const entry = tenant.cache.get(ref);
386
+ const entry = tenant.entries.get(ref);
387
387
  if (entry) {
388
388
  await entry.clear();
389
389
  this.log.debug("Replication cache cleared", {
@@ -404,10 +404,10 @@ class ReplicationCache {
404
404
  }
405
405
 
406
406
  async prune(tenant) {
407
- const maxSize = this.options.size / this.cache.size;
408
- const tenants = tenant ? [tenant] : this.cache.keys();
407
+ const maxSize = this.options.size / this.entries.size;
408
+ const tenants = tenant ? [tenant] : this.entries.keys();
409
409
  for (const id of tenants) {
410
- const tenant = await this.cache.get(id);
410
+ const tenant = await this.entries.get(id);
411
411
  const size = await this.size(tenant.id);
412
412
  let diff = size - maxSize;
413
413
  if (diff > 0) {
@@ -415,12 +415,12 @@ class ReplicationCache {
415
415
  tenant,
416
416
  diff,
417
417
  });
418
- const refs = [...tenant.cache.keys()];
419
- refs.sort((ref1, ref2) => tenant.cache.get(ref1).touched - tenant.cache.get(ref2).touched);
418
+ const refs = [...tenant.entries.keys()];
419
+ refs.sort((ref1, ref2) => tenant.entries.get(ref1).touched - tenant.entries.get(ref2).touched);
420
420
  const pruneRefs = [];
421
421
  for (const ref of refs) {
422
422
  pruneRefs.push(ref);
423
- const entry = tenant.cache.get(ref);
423
+ const entry = tenant.entries.get(ref);
424
424
  if (entry) {
425
425
  diff -= entry.size;
426
426
  if (diff <= 0) {
@@ -429,7 +429,7 @@ class ReplicationCache {
429
429
  }
430
430
  }
431
431
  for (const ref of pruneRefs) {
432
- const entry = tenant.cache.get(ref);
432
+ const entry = tenant.entries.get(ref);
433
433
  this.log.debug("Replication cache prunes ref for tenant", {
434
434
  tenant,
435
435
  ref,
@@ -444,13 +444,13 @@ class ReplicationCache {
444
444
 
445
445
  async size(tenant, ref) {
446
446
  let size = 0;
447
- const tenants = tenant ? [tenant] : this.cache.keys();
447
+ const tenants = tenant ? [tenant] : this.entries.keys();
448
448
  for (const id of tenants) {
449
- const tenant = await this.cache.get(id);
449
+ const tenant = await this.entries.get(id);
450
450
  if (tenant) {
451
- const refs = ref ? [ref] : tenant.cache.keys();
451
+ const refs = ref ? [ref] : tenant.entries.keys();
452
452
  for (const ref of refs) {
453
- const entry = tenant.cache.get(ref);
453
+ const entry = tenant.entries.get(ref);
454
454
  if (entry) {
455
455
  size += entry.size;
456
456
  }
@@ -461,7 +461,7 @@ class ReplicationCache {
461
461
  }
462
462
 
463
463
  async tenantSize(id) {
464
- const tenant = await this.cache.get(id);
464
+ const tenant = await this.entries.get(id);
465
465
  if (tenant) {
466
466
  return await tenant.db.tx(async (tx) => {
467
467
  const result = await tx.run(
@@ -578,16 +578,17 @@ class ReplicationCache {
578
578
  }
579
579
 
580
580
  class ReplicationCacheTenant {
581
- constructor(tenant, model, options) {
581
+ constructor(cache, tenant, model, options) {
582
+ this.cache = cache;
582
583
  this.id = tenant;
583
584
  this.model = model;
584
585
  this.options = options;
585
586
  this.csn = model.definitions;
586
- this.cache = new Map();
587
+ this.entries = new Map();
587
588
  }
588
589
 
589
590
  async prepare() {
590
- this.db = await createDB(this.id, this.model, this.options);
591
+ this.db = await createDB(this.cache.SQLiteService, this.id, this.model, this.options);
591
592
  return this;
592
593
  }
593
594
  }
@@ -697,7 +698,7 @@ class ReplicationCacheEntry {
697
698
  async load(thread) {
698
699
  this.timestamp = Date.now();
699
700
  await this.clear();
700
- if (thread && cds.context && this.service instanceof SQLiteService) {
701
+ if (thread && cds.context && this.service instanceof this.cache.SQLiteService) {
701
702
  const srcTx = this.service.tx(cds.context);
702
703
  await this.db.tx({ tenant: this.tenant.id }, async (destTx) => {
703
704
  await this.loadRecords(srcTx, destTx);
@@ -803,7 +804,7 @@ class ReplicationCacheEntry {
803
804
 
804
805
  module.exports = ReplicationCache;
805
806
 
806
- async function createDB(tenant, model, options) {
807
+ async function createDB(DBService, tenant, model, options) {
807
808
  const filePath = await dbPath(tenant, options);
808
809
  cds.log(Component).debug("Preparing replication cache database", {
809
810
  tenant,
@@ -813,7 +814,7 @@ async function createDB(tenant, model, options) {
813
814
  const templateDatabase = await dbPath(Tenant.Template, options);
814
815
  await fs.copyFile(templateDatabase, filePath);
815
816
  }
816
- const db = new SQLiteService(tenant ?? Tenant.Default, model, {
817
+ const db = new DBService(tenant ?? Tenant.Default, model, {
817
818
  kind: "sqlite",
818
819
  impl: "@cap-js/sqlite",
819
820
  multiTenant: false,