@sockethub/data-layer 1.0.0-alpha.10 → 1.0.0-alpha.12

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sockethub/data-layer",
3
3
  "description": "Storing and RPC of data for Sockethub",
4
- "version": "1.0.0-alpha.10",
4
+ "version": "1.0.0-alpha.12",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "author": "Nick Jennings <nick@silverbucket.net>",
@@ -41,25 +41,27 @@
41
41
  "scripts": {
42
42
  "build": "bun build src/index.ts --outdir dist --target node --format esm --sourcemap=external",
43
43
  "clean": "rm -rf dist",
44
+ "clean:deps": "rm -rf node_modules",
44
45
  "doc": "typedoc --options typedoc.json",
45
46
  "test": "bun test src/",
46
- "test:integration": "DEBUG=ioredis*,bullmq*,sockethub* bun test ./integration/redis.integration.ts"
47
+ "test:integration": "bun test ./integration/redis.integration.ts"
47
48
  },
48
49
  "dependencies": {
49
- "@sockethub/crypto": "^1.0.0-alpha.10",
50
- "@sockethub/schemas": "^3.0.0-alpha.10",
50
+ "@sockethub/crypto": "^1.0.0-alpha.12",
51
+ "@sockethub/logger": "^1.0.0-alpha.12",
52
+ "@sockethub/schemas": "^3.0.0-alpha.12",
51
53
  "bullmq": "^5.66.5",
52
- "debug": "^4.4.3",
53
54
  "ioredis": "^5.9.2",
54
- "secure-store-redis": "^3.0.7"
55
+ "secure-store-redis": "^4.1.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@types/bun": "latest",
58
- "@types/debug": "^4.1.12",
59
59
  "@types/sinon": "^17.0.4",
60
+ "@types/winston": "^2.4.4",
60
61
  "sinon": "^17.0.2",
61
62
  "typedoc": "^0.28.16",
62
- "typedoc-plugin-markdown": "^4.9.0"
63
+ "typedoc-plugin-markdown": "^4.9.0",
64
+ "winston": "^3.19.0"
63
65
  },
64
- "gitHead": "8e1abf116b2a6b57d33c6e1a4af9143870517bae"
66
+ "gitHead": "f039dab3c3f67cbbf204476fc397532e973f82a8"
65
67
  }
@@ -1,8 +1,17 @@
1
1
  import { beforeEach, describe, expect, it } from "bun:test";
2
2
  import * as sinon from "sinon";
3
3
 
4
+ import type { Logger } from "@sockethub/schemas";
5
+
4
6
  import { CredentialsStore } from "./credentials-store";
5
7
 
8
+ const mockLogger: Logger = {
9
+ error: () => {},
10
+ warn: () => {},
11
+ info: () => {},
12
+ debug: () => {},
13
+ };
14
+
6
15
  describe("CredentialsStore", () => {
7
16
  let credentialsStore,
8
17
  MockSecureStore,
@@ -16,6 +25,8 @@ describe("CredentialsStore", () => {
16
25
  MockSecureStore = sinon.stub().returns({
17
26
  get: MockStoreGet,
18
27
  save: MockStoreSave,
28
+ isConnected: true,
29
+ connect: sinon.stub().resolves(),
19
30
  });
20
31
  class TestCredentialsStore extends CredentialsStore {
21
32
  initCrypto() {
@@ -34,6 +45,7 @@ describe("CredentialsStore", () => {
34
45
  "a session id",
35
46
  "a secret must be 32 chars and th",
36
47
  { url: "redis config" },
48
+ mockLogger,
37
49
  );
38
50
  });
39
51
 
@@ -42,11 +54,15 @@ describe("CredentialsStore", () => {
42
54
  sinon.assert.calledWith(MockSecureStore, {
43
55
  namespace: "foo",
44
56
  secret: "a secret must be 32 chars and th",
45
- redis: { url: "redis config" },
57
+ redis: {
58
+ url: "redis config",
59
+ connectionName:
60
+ "data-layer:credentials-store:a parent id:a session id",
61
+ },
46
62
  });
47
63
  expect(typeof credentialsStore).toEqual("object");
48
64
  expect(credentialsStore.uid).toEqual(
49
- `sockethub:data-layer:credentials-store:a parent id:a session id`,
65
+ `sockethub:a parent id:data-layer:credentials-store:a session id`,
50
66
  );
51
67
  expect(typeof credentialsStore.get).toEqual("function");
52
68
  expect(typeof credentialsStore.save).toEqual("function");
@@ -1,10 +1,58 @@
1
1
  import { crypto } from "@sockethub/crypto";
2
+ import {
3
+ createLogger,
4
+ getLoggerNamespace,
5
+ type Logger,
6
+ } from "@sockethub/logger";
2
7
  import type { CredentialsObject } from "@sockethub/schemas";
3
- import debug, { type Debugger } from "debug";
8
+ import IORedis, { type Redis } from "ioredis";
4
9
  import SecureStore from "secure-store-redis";
5
10
 
11
+ import { buildCredentialsStoreId } from "./queue-id.js";
6
12
  import type { RedisConfig } from "./types.js";
7
13
 
14
+ let sharedCredentialsRedisConnection: Redis | null = null;
15
+
16
+ /**
17
+ * Creates or returns a shared Redis connection for CredentialsStore instances.
18
+ * This prevents connection exhaustion by reusing a single connection across
19
+ * all credential storage operations.
20
+ *
21
+ * @param config - Redis configuration
22
+ * @returns Shared Redis connection instance
23
+ */
24
+ export function createCredentialsRedisConnection(config: RedisConfig): Redis {
25
+ if (!sharedCredentialsRedisConnection) {
26
+ sharedCredentialsRedisConnection = new IORedis(config.url, {
27
+ connectionName: config.connectionName,
28
+ enableOfflineQueue: false,
29
+ maxRetriesPerRequest: config.maxRetriesPerRequest ?? null,
30
+ connectTimeout: config.connectTimeout ?? 10000,
31
+ disconnectTimeout: config.disconnectTimeout ?? 5000,
32
+ lazyConnect: false,
33
+ retryStrategy: (times: number) => {
34
+ if (times > 3) return null;
35
+ return Math.min(2 ** (times - 1) * 200, 2000);
36
+ },
37
+ });
38
+ }
39
+ return sharedCredentialsRedisConnection;
40
+ }
41
+
42
+ /**
43
+ * Resets the shared credentials Redis connection. Used primarily for testing.
44
+ */
45
+ export async function resetSharedCredentialsRedisConnection(): Promise<void> {
46
+ if (sharedCredentialsRedisConnection) {
47
+ try {
48
+ sharedCredentialsRedisConnection.disconnect(false);
49
+ } catch (_err) {
50
+ // Ignore disconnect errors during cleanup
51
+ }
52
+ sharedCredentialsRedisConnection = null;
53
+ }
54
+ }
55
+
8
56
  export interface CredentialsStoreInterface {
9
57
  get(
10
58
  actor: string,
@@ -14,13 +62,16 @@ export interface CredentialsStoreInterface {
14
62
  }
15
63
 
16
64
  export async function verifySecureStore(config: RedisConfig): Promise<void> {
17
- const log = debug("sockethub:data-layer:credentials-store");
65
+ const log = createLogger("data-layer:verify-secure-store");
66
+ const sharedClient = createCredentialsRedisConnection(config);
18
67
  const ss = new SecureStore({
19
- redis: config,
68
+ uid: "data-layer:verify",
69
+ secret: "aB3#xK9mP2qR7wZ4cT8nY6vH1jL5fD0s",
70
+ redis: { client: sharedClient },
20
71
  });
21
- await ss.init();
72
+ await ss.connect();
22
73
  await ss.disconnect();
23
- log("redis connection verified");
74
+ log.info("secure store connection verified");
24
75
  }
25
76
 
26
77
  /**
@@ -49,7 +100,7 @@ export class CredentialsStore implements CredentialsStoreInterface {
49
100
  readonly uid: string;
50
101
  store: SecureStore;
51
102
  objectHash: (o: unknown) => string;
52
- private readonly log: Debugger;
103
+ private readonly log: Logger;
53
104
 
54
105
  /**
55
106
  * Creates a new CredentialsStore instance.
@@ -71,11 +122,19 @@ export class CredentialsStore implements CredentialsStoreInterface {
71
122
  "CredentialsStore secret must be 32 chars in length",
72
123
  );
73
124
  }
74
- this.uid = `sockethub:data-layer:credentials-store:${parentId}:${sessionId}`;
75
- this.log = debug(this.uid);
125
+ // Create logger with full namespace (context will be prepended automatically)
126
+ this.log = createLogger(
127
+ `data-layer:credentials-store:${parentId}:${sessionId}`,
128
+ );
129
+
76
130
  this.initCrypto();
131
+
132
+ // Use the canonical, context-free namespace for credentials storage keys
133
+ this.uid = buildCredentialsStoreId(parentId, sessionId);
134
+ // Keep full logger namespace for Redis connection naming
135
+ redisConfig.connectionName = getLoggerNamespace(this.log);
77
136
  this.initSecureStore(secret, redisConfig);
78
- this.log("initialized");
137
+ this.log.debug("initialized");
79
138
  }
80
139
 
81
140
  initCrypto() {
@@ -83,23 +142,29 @@ export class CredentialsStore implements CredentialsStoreInterface {
83
142
  }
84
143
 
85
144
  initSecureStore(secret: string, redisConfig: RedisConfig) {
145
+ // Use shared Redis connection for connection pooling
146
+ const sharedClient = createCredentialsRedisConnection(redisConfig);
86
147
  this.store = new SecureStore({
87
148
  uid: this.uid,
88
149
  secret: secret,
89
- redis: redisConfig,
150
+ redis: { client: sharedClient },
90
151
  });
91
152
  }
92
153
 
93
154
  /**
94
- * Gets the credentials for a given actor ID
155
+ * Gets the credentials for a given actor ID.
95
156
  * @param actor
96
- * @param credentialsHash - Optional hash to validate credentials. If undefined, validation is skipped.
157
+ * @param credentialsHash - Optional hash to validate credentials.
158
+ * If undefined, validation is skipped.
97
159
  */
98
160
  async get(
99
161
  actor: string,
100
162
  credentialsHash: string | undefined,
101
163
  ): Promise<CredentialsObject> {
102
- this.log(`get credentials for ${actor}`);
164
+ this.log.debug(`get credentials for ${actor}`);
165
+ if (!this.store.isConnected) {
166
+ await this.store.connect();
167
+ }
103
168
  const credentials: CredentialsObject = await this.store.get(actor);
104
169
  if (!credentials) {
105
170
  throw new Error(`credentials not found for ${actor}`);
@@ -119,6 +184,9 @@ export class CredentialsStore implements CredentialsStoreInterface {
119
184
  * @param creds
120
185
  */
121
186
  async save(actor: string, creds: CredentialsObject): Promise<number> {
122
- return this.store.save(actor, creds);
187
+ if (!this.store.isConnected) {
188
+ await this.store.connect();
189
+ }
190
+ return await this.store.save(actor, creds);
123
191
  }
124
192
  }
package/src/index.ts CHANGED
@@ -1,19 +1,25 @@
1
- import debug from "debug";
1
+ import { createLogger } from "@sockethub/logger";
2
2
 
3
3
  import {
4
4
  CredentialsStore,
5
5
  type CredentialsStoreInterface,
6
+ resetSharedCredentialsRedisConnection,
6
7
  verifySecureStore,
7
8
  } from "./credentials-store.js";
9
+ import {
10
+ getRedisConnectionCount,
11
+ resetSharedRedisConnection,
12
+ } from "./job-base.js";
8
13
  import { JobQueue, verifyJobQueue } from "./job-queue.js";
9
14
  import { JobWorker } from "./job-worker.js";
15
+
10
16
  export * from "./types.js";
11
- import type { RedisConfig } from "./types.js";
12
17
 
13
- const log = debug("sockethub:data-layer");
18
+ import type { RedisConfig } from "./types.js";
14
19
 
15
20
  async function redisCheck(config: RedisConfig): Promise<void> {
16
- log(`checking redis connection ${config.url}`);
21
+ const log = createLogger("data-layer:redis-check");
22
+ log.debug(`checking redis connection ${config.url}`);
17
23
  await verifySecureStore(config);
18
24
  await verifyJobQueue(config);
19
25
  }
@@ -23,5 +29,8 @@ export {
23
29
  JobQueue,
24
30
  JobWorker,
25
31
  CredentialsStore,
32
+ getRedisConnectionCount,
33
+ resetSharedRedisConnection,
34
+ resetSharedCredentialsRedisConnection,
26
35
  type CredentialsStoreInterface,
27
36
  };
package/src/job-base.ts CHANGED
@@ -1,16 +1,83 @@
1
1
  import EventEmitter from "node:events";
2
- import IORedis, { type Redis } from "ioredis";
3
-
4
- import { crypto, type Crypto } from "@sockethub/crypto";
2
+ import { type Crypto, crypto } from "@sockethub/crypto";
5
3
  import type { ActivityStream } from "@sockethub/schemas";
4
+ import IORedis, { type Redis } from "ioredis";
6
5
 
7
6
  import type { JobDataDecrypted, JobEncrypted, RedisConfig } from "./types.js";
8
7
 
8
+ let sharedRedisConnection: Redis | null = null;
9
+
10
+ /**
11
+ * Creates or returns a shared Redis connection to enable connection pooling.
12
+ * This prevents connection exhaustion under high load by reusing a single
13
+ * connection across all JobQueue and JobWorker instances.
14
+ *
15
+ * @param config - Redis configuration with optional timeout and retry settings
16
+ * @returns Shared Redis connection instance
17
+ */
9
18
  export function createIORedisConnection(config: RedisConfig): Redis {
10
- return new IORedis(config.url, {
11
- enableOfflineQueue: false,
12
- maxRetriesPerRequest: null,
13
- });
19
+ if (!sharedRedisConnection) {
20
+ sharedRedisConnection = new IORedis(config.url, {
21
+ connectionName: config.connectionName,
22
+ enableOfflineQueue: false,
23
+ maxRetriesPerRequest: config.maxRetriesPerRequest ?? null,
24
+ connectTimeout: config.connectTimeout ?? 10000,
25
+ disconnectTimeout: config.disconnectTimeout ?? 5000,
26
+ lazyConnect: false,
27
+ retryStrategy: (times: number) => {
28
+ // Stop retrying after 3 attempts to fail fast
29
+ if (times > 3) return null;
30
+ // Exponential backoff: 200ms, 400ms, 800ms
31
+ return Math.min(2 ** (times - 1) * 200, 2000);
32
+ },
33
+ });
34
+ }
35
+ return sharedRedisConnection;
36
+ }
37
+
38
+ /**
39
+ * Resets the shared Redis connection. Used primarily for testing.
40
+ * Disconnects the current connection before resetting.
41
+ */
42
+ export async function resetSharedRedisConnection(): Promise<void> {
43
+ if (sharedRedisConnection) {
44
+ try {
45
+ sharedRedisConnection.disconnect(false);
46
+ } catch (_err) {
47
+ // Ignore disconnect errors during cleanup
48
+ }
49
+ sharedRedisConnection = null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Gets the total number of active Redis client connections.
55
+ *
56
+ * Note: This queries the Redis server directly using CLIENT LIST and reports
57
+ * ALL active connections to the Redis instance. This includes connections from
58
+ * Sockethub (BullMQ queues, workers, and the shared connection) as well as any
59
+ * other applications or services connected to the same Redis server.
60
+ *
61
+ * @returns Number of active Redis connections, or 0 if no connection exists
62
+ */
63
+ export async function getRedisConnectionCount(): Promise<number> {
64
+ if (!sharedRedisConnection) {
65
+ return 0;
66
+ }
67
+
68
+ try {
69
+ const clientList = (await sharedRedisConnection.client(
70
+ "LIST",
71
+ )) as string;
72
+ // CLIENT LIST returns one line per connection, filter out empty lines
73
+ const connections = clientList
74
+ .split("\n")
75
+ .filter((line: string) => line.trim());
76
+ return connections.length;
77
+ } catch (_err) {
78
+ // Return 0 if Redis query fails (connection issues, etc.)
79
+ return 0;
80
+ }
14
81
  }
15
82
 
16
83
  export class JobBase extends EventEmitter {
@@ -1,8 +1,17 @@
1
1
  import { expect } from "chai";
2
2
  import * as sinon from "sinon";
3
3
 
4
+ import type { Logger } from "@sockethub/schemas";
5
+
4
6
  import { JobQueue } from "./index";
5
7
 
8
+ const mockLogger: Logger = {
9
+ error: () => {},
10
+ warn: () => {},
11
+ info: () => {},
12
+ debug: () => {},
13
+ };
14
+
6
15
  describe("JobQueue", () => {
7
16
  let MockBull, jobQueue, cryptoMocks, sandbox;
8
17
 
@@ -31,17 +40,22 @@ describe("JobQueue", () => {
31
40
 
32
41
  class TestJobQueue extends JobQueue {
33
42
  init() {
43
+ // BullMQ v5+ prohibits colons in queue names, so replace with dashes
44
+ const queueName = this.queueId.replace(/:/g, "-");
34
45
  this.redisConnection = MockBull();
35
- this.queue = MockBull(this.uid, {
46
+ this.queue = MockBull(queueName, {
36
47
  connection: this.redisConnection,
37
48
  });
38
- this.events = MockBull(this.uid, {
49
+ this.events = MockBull(queueName, {
39
50
  connection: this.redisConnection,
40
51
  });
41
52
  }
42
53
  initCrypto() {
43
54
  this.crypto = cryptoMocks;
44
55
  }
56
+ getQueueId() {
57
+ return this.queueId;
58
+ }
45
59
  }
46
60
  jobQueue = new TestJobQueue(
47
61
  "a parent id",
@@ -50,6 +64,7 @@ describe("JobQueue", () => {
50
64
  {
51
65
  url: "redis config",
52
66
  },
67
+ mockLogger,
53
68
  );
54
69
  jobQueue.emit = sandbox.stub();
55
70
  });
@@ -63,14 +78,14 @@ describe("JobQueue", () => {
63
78
  sinon.assert.calledThrice(MockBull);
64
79
  sinon.assert.calledWith(
65
80
  MockBull,
66
- "sockethub:data-layer:queue:a parent id:a session id",
81
+ "sockethub-a parent id-data-layer-queue-a session id",
67
82
  {
68
83
  connection: MockBull(),
69
84
  },
70
85
  );
71
86
  expect(typeof jobQueue).to.equal("object");
72
- expect(jobQueue.uid).to.equal(
73
- `sockethub:data-layer:queue:a parent id:a session id`,
87
+ expect(jobQueue.getQueueId()).to.equal(
88
+ `sockethub:a parent id:data-layer:queue:a session id`,
74
89
  );
75
90
  expect(typeof jobQueue.add).to.equal("function");
76
91
  expect(typeof jobQueue.getJob).to.equal("function");
@@ -93,7 +108,7 @@ describe("JobQueue", () => {
93
108
  it("returns expected job format", () => {
94
109
  cryptoMocks.encrypt.returns("an encrypted message");
95
110
  const job = jobQueue.createJob("a socket id", {
96
- context: "some context",
111
+ platform: "some context",
97
112
  id: "an identifier",
98
113
  });
99
114
  expect(job).to.eql({
@@ -106,7 +121,7 @@ describe("JobQueue", () => {
106
121
  it("uses counter when no id provided", () => {
107
122
  cryptoMocks.encrypt.returns("an encrypted message");
108
123
  let job = jobQueue.createJob("a socket id", {
109
- context: "some context",
124
+ platform: "some context",
110
125
  });
111
126
  expect(job).to.eql({
112
127
  title: "some context-0",
@@ -114,7 +129,7 @@ describe("JobQueue", () => {
114
129
  sessionId: "a socket id",
115
130
  });
116
131
  job = jobQueue.createJob("a socket id", {
117
- context: "some context",
132
+ platform: "some context",
118
133
  });
119
134
  expect(job).to.eql({
120
135
  title: "some context-1",
@@ -186,7 +201,7 @@ describe("JobQueue", () => {
186
201
  msg: "encrypted foo",
187
202
  };
188
203
  const res = await jobQueue.add("a socket id", {
189
- context: "a platform",
204
+ platform: "a platform",
190
205
  id: "an identifier",
191
206
  });
192
207
  sinon.assert.calledOnce(jobQueue.queue.isPaused);
@@ -207,7 +222,7 @@ describe("JobQueue", () => {
207
222
  jobQueue.queue.isPaused.returns(true);
208
223
  try {
209
224
  await jobQueue.add("a socket id", {
210
- context: "a platform",
225
+ platform: "a platform",
211
226
  id: "an identifier",
212
227
  });
213
228
  } catch (err) {