@jordanalec/dtk 1.0.7 → 1.2.0

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/README.md CHANGED
@@ -286,6 +286,57 @@ AWS credentials are resolved from the environment via the SDK default provider c
286
286
 
287
287
  ---
288
288
 
289
+ ### redis
290
+
291
+ Reads and writes data in a Redis cache.
292
+
293
+ ```bash
294
+ dtk add redis
295
+ ```
296
+
297
+ Env vars appended to `.env.template`:
298
+
299
+ ```
300
+ REDIS_URL=redis://localhost:6379
301
+ ```
302
+
303
+ Usage:
304
+
305
+ ```ts
306
+ await suite()
307
+ .redis({ url: process.env.REDIS_URL! })
308
+ .step("write", async (ctx) => {
309
+ await ctx.services.redis.set("key", "value", 3600);
310
+ })
311
+ .step("read", async (ctx) => {
312
+ const value = await ctx.services.redis.get("key");
313
+ console.log("value:", value);
314
+ return value;
315
+ })
316
+ .step("disconnect", async (ctx) => {
317
+ await ctx.services.redis.quit();
318
+ })
319
+ .run("stopOnError");
320
+ ```
321
+
322
+ > **The `disconnect` step is required.** The Redis client holds an open TCP connection. Without calling `quit()`, the Node.js process will hang indefinitely after the runbook finishes. Always use `stopOnError` (not `throwOnError`) so that the `disconnect` step is guaranteed to run even when an earlier step fails.
323
+
324
+ Available methods on `ctx.services.redis`:
325
+
326
+ | Method | Description |
327
+ |---|---|
328
+ | `get(key)` | Returns the string value or `null` if the key does not exist |
329
+ | `set(key, value, ttlSeconds?)` | Sets a key, with an optional TTL in seconds |
330
+ | `del(key)` | Deletes a key, returns the number of keys removed |
331
+ | `exists(key)` | Returns `true` if the key exists |
332
+ | `expire(key, ttlSeconds)` | Sets a TTL on an existing key, returns `true` if applied |
333
+ | `hset(key, field, value)` | Sets a field on a hash, returns the number of new fields added |
334
+ | `hget(key, field)` | Returns a hash field value or `null` |
335
+ | `keys(pattern)` | Returns all keys matching a glob pattern (e.g. `user:*`) |
336
+ | `quit()` | Closes the connection -- must be called at the end of every runbook |
337
+
338
+ ---
339
+
289
340
  ### open-ai
290
341
 
291
342
  Lists models and sends responses via the OpenAI API.
@@ -318,6 +369,76 @@ await suite()
318
369
 
319
370
  ---
320
371
 
372
+ ### sql
373
+
374
+ Runs raw SQL queries, executes statements, calls stored procedures, and runs transactions against PostgreSQL, MySQL, or MSSQL via knex.
375
+
376
+ ```bash
377
+ dtk add sql
378
+ ```
379
+
380
+ Env vars appended to `.env.template`:
381
+
382
+ ```
383
+ SQL_CONNECTION_STRING=
384
+ ```
385
+
386
+ Because the sql service holds an open connection pool, it must always be disconnected after use. Create the service outside the suite and call `disconnect()` in a `finally` block so it is guaranteed to run even when a step fails:
387
+
388
+ ```ts
389
+ import "../load-env.js";
390
+ import { suite } from "../suite.js";
391
+ import { createSqlService } from "../services/sql.js";
392
+
393
+ const sql = createSqlService({
394
+ client: 'pg', // or 'mysql2' or 'mssql'
395
+ connection: process.env.SQL_CONNECTION_STRING!,
396
+ });
397
+
398
+ try {
399
+ await suite()
400
+ .step("query", async () => {
401
+ const rows = await sql.query<{ id: number; name: string }>(
402
+ "SELECT id, name FROM users WHERE active = ?",
403
+ [true]
404
+ );
405
+ console.log(rows);
406
+ return rows;
407
+ })
408
+ .step("insert", async () => {
409
+ const affected = await sql.execute(
410
+ "INSERT INTO users (name, email, active) VALUES (?, ?, ?)",
411
+ ["Alice", "alice@example.com", true]
412
+ );
413
+ console.log("rows inserted:", affected);
414
+ return affected;
415
+ })
416
+ .step("transaction", async () => {
417
+ await sql.transaction(async (ops) => {
418
+ await ops.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
419
+ await ops.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
420
+ });
421
+ })
422
+ .run("stopOnError");
423
+ } finally {
424
+ await sql.disconnect();
425
+ }
426
+ ```
427
+
428
+ Available methods on the sql service:
429
+
430
+ | Method | Description |
431
+ |---|---|
432
+ | `query<T>(sql, params?)` | Runs a SELECT and returns the result rows as `T[]` |
433
+ | `execute(sql, params?)` | Runs an INSERT / UPDATE / DELETE and returns the affected row count |
434
+ | `callProc<T>(name, params?)` | Calls a stored procedure and returns result rows as `T[]` |
435
+ | `transaction(fn)` | Runs `fn` inside a transaction; rolls back automatically on error |
436
+ | `disconnect()` | Destroys the connection pool -- always call this in a `finally` block |
437
+
438
+ Supported clients: `pg` (PostgreSQL), `mysql2` (MySQL / MariaDB), `mssql` (SQL Server).
439
+
440
+ ---
441
+
321
442
  ## Writing runbooks
322
443
 
323
444
  A runbook is a TypeScript file that uses the `suite()` builder to chain steps and run them in sequence.
@@ -728,4 +849,6 @@ templates/
728
849
  aws-dynamo/
729
850
  aws-s3/
730
851
  open-ai/
852
+ redis/
853
+ sql/
731
854
  ```
package/dist/add.js CHANGED
@@ -12,6 +12,8 @@ const PLUGIN_MAP = {
12
12
  'aws-dynamo': 'aws-dynamo',
13
13
  'aws-s3': 'aws-s3',
14
14
  'open-ai': 'open-ai',
15
+ 'redis': 'redis',
16
+ 'sql': 'sql',
15
17
  };
16
18
  export const addCommand = new Command('add')
17
19
  .description('Add a service plugin to the project')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jordanalec/dtk",
3
- "version": "1.0.7",
3
+ "version": "1.2.0",
4
4
  "description": "CLI scaffolding tool for generating self-contained TypeScript runbook projects",
5
5
  "keywords": [
6
6
  "cli",
@@ -45,6 +45,8 @@
45
45
  "axios": "^1.6.0",
46
46
  "dotenv": "^16.4.0",
47
47
  "jest": "^30.3.0",
48
+ "knex": "^3.1.0",
49
+ "redis": "^4.7.1",
48
50
  "ts-jest": "^29.4.9",
49
51
  "tsx": "^4.0.0",
50
52
  "typescript": "^5.0.0"
@@ -382,6 +382,57 @@ AWS credentials are picked up automatically from `AWS_ACCESS_KEY_ID` and `AWS_SE
382
382
 
383
383
  ---
384
384
 
385
+ ### redis
386
+
387
+ Reads and writes data in a Redis cache.
388
+
389
+ ```bash
390
+ dtk add redis
391
+ ```
392
+
393
+ Required env vars:
394
+
395
+ ```
396
+ REDIS_URL=redis://localhost:6379
397
+ ```
398
+
399
+ Usage:
400
+
401
+ ```ts
402
+ await suite()
403
+ .redis({ url: process.env.REDIS_URL! })
404
+ .step("write", async (ctx) => {
405
+ await ctx.services.redis.set("key", "value", 3600);
406
+ })
407
+ .step("read", async (ctx) => {
408
+ const value = await ctx.services.redis.get("key");
409
+ console.log("value:", value);
410
+ return value;
411
+ })
412
+ .step("disconnect", async (ctx) => {
413
+ await ctx.services.redis.quit();
414
+ })
415
+ .run("stopOnError");
416
+ ```
417
+
418
+ > **The `disconnect` step is required.** The Redis client holds an open TCP connection. Without calling `quit()`, the Node.js process will hang indefinitely after the runbook finishes. Always use `stopOnError` (not `throwOnError`) so that the `disconnect` step is guaranteed to run even when an earlier step fails.
419
+
420
+ Available methods on `ctx.services.redis`:
421
+
422
+ | Method | Description |
423
+ |---|---|
424
+ | `get(key)` | Returns the string value or `null` if the key does not exist |
425
+ | `set(key, value, ttlSeconds?)` | Sets a key, with an optional TTL in seconds |
426
+ | `del(key)` | Deletes a key, returns the number of keys removed |
427
+ | `exists(key)` | Returns `true` if the key exists |
428
+ | `expire(key, ttlSeconds)` | Sets a TTL on an existing key, returns `true` if applied |
429
+ | `hset(key, field, value)` | Sets a field on a hash, returns the number of new fields added |
430
+ | `hget(key, field)` | Returns a hash field value or `null` |
431
+ | `keys(pattern)` | Returns all keys matching a glob pattern (e.g. `user:*`) |
432
+ | `quit()` | Closes the connection -- must be called at the end of every runbook |
433
+
434
+ ---
435
+
385
436
  ### open-ai
386
437
 
387
438
  Lists models and sends responses via the OpenAI API.
@@ -410,6 +461,76 @@ await suite()
410
461
 
411
462
  ---
412
463
 
464
+ ### sql
465
+
466
+ Runs raw SQL queries, executes statements, calls stored procedures, and runs transactions against PostgreSQL, MySQL, or MSSQL via knex.
467
+
468
+ ```bash
469
+ dtk add sql
470
+ ```
471
+
472
+ Required env vars:
473
+
474
+ ```
475
+ SQL_CONNECTION_STRING=
476
+ ```
477
+
478
+ Because the sql service holds an open connection pool, it must always be disconnected after use. Create the service outside the suite and call `disconnect()` in a `finally` block so it is guaranteed to run even when a step fails:
479
+
480
+ ```ts
481
+ import "../load-env.js";
482
+ import { suite } from "../suite.js";
483
+ import { createSqlService } from "../services/sql.js";
484
+
485
+ const sql = createSqlService({
486
+ client: 'pg', // or 'mysql2' or 'mssql'
487
+ connection: process.env.SQL_CONNECTION_STRING!,
488
+ });
489
+
490
+ try {
491
+ await suite()
492
+ .step("query", async () => {
493
+ const rows = await sql.query<{ id: number; name: string }>(
494
+ "SELECT id, name FROM users WHERE active = ?",
495
+ [true]
496
+ );
497
+ console.log(rows);
498
+ return rows;
499
+ })
500
+ .step("insert", async () => {
501
+ const affected = await sql.execute(
502
+ "INSERT INTO users (name, email, active) VALUES (?, ?, ?)",
503
+ ["Alice", "alice@example.com", true]
504
+ );
505
+ console.log("rows inserted:", affected);
506
+ return affected;
507
+ })
508
+ .step("transaction", async () => {
509
+ await sql.transaction(async (ops) => {
510
+ await ops.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
511
+ await ops.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
512
+ });
513
+ })
514
+ .run("stopOnError");
515
+ } finally {
516
+ await sql.disconnect();
517
+ }
518
+ ```
519
+
520
+ Available methods on the sql service:
521
+
522
+ | Method | Description |
523
+ |---|---|
524
+ | `query<T>(sql, params?)` | Runs a SELECT and returns the result rows as `T[]` |
525
+ | `execute(sql, params?)` | Runs an INSERT / UPDATE / DELETE and returns the affected row count |
526
+ | `callProc<T>(name, params?)` | Calls a stored procedure and returns result rows as `T[]` |
527
+ | `transaction(fn)` | Runs `fn` inside a transaction; rolls back automatically on error |
528
+ | `disconnect()` | Destroys the connection pool -- always call this in a `finally` block |
529
+
530
+ Supported clients: `pg` (PostgreSQL), `mysql2` (MySQL / MariaDB), `mssql` (SQL Server).
531
+
532
+ ---
533
+
413
534
  ## Writing a custom service
414
535
 
415
536
  If there is no plugin for the service you need, wire one in manually. Four files are involved.
@@ -37,7 +37,7 @@ Install the dtk CLI if you haven't already, then run from this directory:
37
37
  dtk add <plugin>
38
38
  ```
39
39
 
40
- Available plugins: `aws-sqs`, `aws-sns`, `aws-dynamo`, `aws-s3`, `open-ai`
40
+ Available plugins: `aws-sqs`, `aws-sns`, `aws-dynamo`, `aws-s3`, `open-ai`, `redis`, `sql`
41
41
 
42
42
  Each plugin adds a service, wires it into the suite, appends env vars to `.env.template`, creates an example runbook, and installs dependencies automatically.
43
43
 
@@ -0,0 +1 @@
1
+ REDIS_URL=
@@ -0,0 +1,37 @@
1
+ import "../load-env.js";
2
+ import { suite } from "../suite.js";
3
+
4
+ await suite()
5
+ .redis({
6
+ url: process.env.REDIS_URL!,
7
+ })
8
+ .step("write-session", async (ctx) => {
9
+ await ctx.services.redis.set("session:abc123", JSON.stringify({ userId: 42, role: "admin" }), 3600);
10
+ console.log("session written with 1h TTL");
11
+ })
12
+ .step("read-session", async (ctx) => {
13
+ const raw = await ctx.services.redis.get("session:abc123");
14
+ const session = raw ? JSON.parse(raw) : null;
15
+ console.log("session:", session);
16
+ return session;
17
+ })
18
+ .step("check-existence", async (ctx) => {
19
+ const exists = await ctx.services.redis.exists("session:abc123");
20
+ console.log("session exists:", exists);
21
+ return exists;
22
+ })
23
+ .step("hash-metadata", async (ctx) => {
24
+ await ctx.services.redis.hset("user:42", "lastSeen", new Date().toISOString());
25
+ const lastSeen = await ctx.services.redis.hget("user:42", "lastSeen");
26
+ console.log("user:42 lastSeen:", lastSeen);
27
+ return lastSeen;
28
+ })
29
+ .step("list-session-keys", async (ctx) => {
30
+ const keys = await ctx.services.redis.keys("session:*");
31
+ console.log("active sessions:", keys);
32
+ return keys;
33
+ })
34
+ .step("disconnect", async (ctx) => {
35
+ await ctx.services.redis.quit();
36
+ })
37
+ .run("stopOnError");
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "redis",
3
+ "description": "Redis -- get, set, del, exists, expire, hset, hget, keys",
4
+ "dependencies": {
5
+ "redis": "^4.7.0"
6
+ },
7
+ "files": [
8
+ { "src": "service.ts", "dest": "src/services/redis.ts" },
9
+ { "src": "types.ts", "dest": "src/types/redis.ts" },
10
+ { "src": "service.test.ts", "dest": "src/services/redis.test.ts" }
11
+ ],
12
+ "env": "env.txt",
13
+ "example": "example.ts",
14
+ "transforms": {
15
+ "service.ts": [
16
+ { "from": "./types.js", "to": "../types/redis.js" }
17
+ ],
18
+ "service.test.ts": [
19
+ { "from": "./service.js", "to": "./redis.js" }
20
+ ]
21
+ },
22
+ "patches": {
23
+ "src/suite.ts": {
24
+ "imports": [
25
+ "import { createRedisService } from \"./services/redis.js\";",
26
+ "import type { RedisConfig } from \"./types/redis.js\";"
27
+ ],
28
+ "configs": " private redisConfig?: RedisConfig;",
29
+ "methods": " redis(config: RedisConfig): this { this.redisConfig = config; return this; }",
30
+ "services": " redis: createRedisService(this.redisConfig),"
31
+ },
32
+ "src/types/suite.ts": {
33
+ "service-types": " redis: { get(key: string): Promise<string | null>; set(key: string, value: string, ttlSeconds?: number): Promise<void>; del(key: string): Promise<number>; exists(key: string): Promise<boolean>; expire(key: string, ttlSeconds: number): Promise<boolean>; hset(key: string, field: string, value: string): Promise<number>; hget(key: string, field: string): Promise<string | null>; keys(pattern: string): Promise<string[]>; quit(): Promise<void>; };"
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,149 @@
1
+ import { createRedisService } from './service.js';
2
+
3
+ jest.mock('redis');
4
+ import { createClient } from 'redis';
5
+
6
+ const mockClient = {
7
+ connect: jest.fn().mockResolvedValue(undefined),
8
+ get: jest.fn(),
9
+ set: jest.fn().mockResolvedValue('OK'),
10
+ del: jest.fn(),
11
+ exists: jest.fn(),
12
+ expire: jest.fn(),
13
+ hSet: jest.fn(),
14
+ hGet: jest.fn(),
15
+ keys: jest.fn(),
16
+ quit: jest.fn().mockResolvedValue(undefined),
17
+ };
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ (createClient as jest.Mock).mockReturnValue(mockClient);
22
+ mockClient.connect.mockResolvedValue(undefined);
23
+ });
24
+
25
+ describe('createRedisService', () => {
26
+ const config = { url: 'redis://localhost:6379' };
27
+
28
+ it('connects to redis using the provided url', async () => {
29
+ mockClient.get.mockResolvedValue('value');
30
+ const redis = createRedisService(config);
31
+ await redis.get('key');
32
+ expect(createClient).toHaveBeenCalledWith({ url: config.url });
33
+ expect(mockClient.connect).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ it('reuses the same client across multiple calls', async () => {
37
+ mockClient.get.mockResolvedValue('value');
38
+ const redis = createRedisService(config);
39
+ await redis.get('key1');
40
+ await redis.get('key2');
41
+ expect(mockClient.connect).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it('get returns the value for an existing key', async () => {
45
+ mockClient.get.mockResolvedValue('hello');
46
+ const redis = createRedisService(config);
47
+ const result = await redis.get('greeting');
48
+ expect(result).toBe('hello');
49
+ expect(mockClient.get).toHaveBeenCalledWith('greeting');
50
+ });
51
+
52
+ it('get returns null for a missing key', async () => {
53
+ mockClient.get.mockResolvedValue(null);
54
+ const redis = createRedisService(config);
55
+ const result = await redis.get('missing');
56
+ expect(result).toBeNull();
57
+ });
58
+
59
+ it('set calls client.set without EX when no ttl is given', async () => {
60
+ const redis = createRedisService(config);
61
+ await redis.set('key', 'value');
62
+ expect(mockClient.set).toHaveBeenCalledWith('key', 'value');
63
+ });
64
+
65
+ it('set calls client.set with EX option when ttl is given', async () => {
66
+ const redis = createRedisService(config);
67
+ await redis.set('key', 'value', 60);
68
+ expect(mockClient.set).toHaveBeenCalledWith('key', 'value', { EX: 60 });
69
+ });
70
+
71
+ it('del returns the number of deleted keys', async () => {
72
+ mockClient.del.mockResolvedValue(1);
73
+ const redis = createRedisService(config);
74
+ const result = await redis.del('key');
75
+ expect(result).toBe(1);
76
+ expect(mockClient.del).toHaveBeenCalledWith('key');
77
+ });
78
+
79
+ it('exists returns true when key is present', async () => {
80
+ mockClient.exists.mockResolvedValue(1);
81
+ const redis = createRedisService(config);
82
+ const result = await redis.exists('key');
83
+ expect(result).toBe(true);
84
+ });
85
+
86
+ it('exists returns false when key is absent', async () => {
87
+ mockClient.exists.mockResolvedValue(0);
88
+ const redis = createRedisService(config);
89
+ const result = await redis.exists('missing');
90
+ expect(result).toBe(false);
91
+ });
92
+
93
+ it('expire returns true when the timeout was set', async () => {
94
+ mockClient.expire.mockResolvedValue(true);
95
+ const redis = createRedisService(config);
96
+ const result = await redis.expire('key', 300);
97
+ expect(result).toBe(true);
98
+ expect(mockClient.expire).toHaveBeenCalledWith('key', 300);
99
+ });
100
+
101
+ it('hset returns the number of new fields added', async () => {
102
+ mockClient.hSet.mockResolvedValue(1);
103
+ const redis = createRedisService(config);
104
+ const result = await redis.hset('hash', 'field', 'value');
105
+ expect(result).toBe(1);
106
+ expect(mockClient.hSet).toHaveBeenCalledWith('hash', 'field', 'value');
107
+ });
108
+
109
+ it('hget returns the field value', async () => {
110
+ mockClient.hGet.mockResolvedValue('stored');
111
+ const redis = createRedisService(config);
112
+ const result = await redis.hget('hash', 'field');
113
+ expect(result).toBe('stored');
114
+ });
115
+
116
+ it('hget returns null when field is undefined', async () => {
117
+ mockClient.hGet.mockResolvedValue(undefined);
118
+ const redis = createRedisService(config);
119
+ const result = await redis.hget('hash', 'missing');
120
+ expect(result).toBeNull();
121
+ });
122
+
123
+ it('keys returns matching key names', async () => {
124
+ mockClient.keys.mockResolvedValue(['user:1', 'user:2']);
125
+ const redis = createRedisService(config);
126
+ const result = await redis.keys('user:*');
127
+ expect(result).toEqual(['user:1', 'user:2']);
128
+ expect(mockClient.keys).toHaveBeenCalledWith('user:*');
129
+ });
130
+
131
+ it('quit disconnects the client and clears it', async () => {
132
+ mockClient.get.mockResolvedValue('value');
133
+ mockClient.quit = jest.fn().mockResolvedValue(undefined);
134
+ const redis = createRedisService(config);
135
+ await redis.get('key');
136
+ await redis.quit();
137
+ expect(mockClient.quit).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it('quit is a no-op when client was never connected', async () => {
141
+ const redis = createRedisService(config);
142
+ await expect(redis.quit()).resolves.toBeUndefined();
143
+ });
144
+
145
+ it('throws when any method is called without config', async () => {
146
+ const redis = createRedisService();
147
+ await expect(redis.get('key')).rejects.toThrow('redis service is not configured');
148
+ });
149
+ });
@@ -0,0 +1,66 @@
1
+ import { createClient } from "redis";
2
+ import type { RedisConfig } from "./types.js";
3
+
4
+ export function createRedisService(config?: RedisConfig) {
5
+ const ensureConfig = () => {
6
+ if (!config) throw new Error("redis service is not configured -- call .redis(config) on the suite");
7
+ };
8
+
9
+ let client: ReturnType<typeof createClient> | null = null;
10
+
11
+ const getClient = async () => {
12
+ ensureConfig();
13
+ if (!client) {
14
+ client = createClient({ url: config!.url });
15
+ await client.connect();
16
+ }
17
+ return client;
18
+ };
19
+
20
+ return {
21
+ get: async (key: string): Promise<string | null> => {
22
+ const c = await getClient();
23
+ return c.get(key);
24
+ },
25
+ set: async (key: string, value: string, ttlSeconds?: number): Promise<void> => {
26
+ const c = await getClient();
27
+ if (ttlSeconds !== undefined) {
28
+ await c.set(key, value, { EX: ttlSeconds });
29
+ } else {
30
+ await c.set(key, value);
31
+ }
32
+ },
33
+ del: async (key: string): Promise<number> => {
34
+ const c = await getClient();
35
+ return c.del(key);
36
+ },
37
+ exists: async (key: string): Promise<boolean> => {
38
+ const c = await getClient();
39
+ const count = await c.exists(key);
40
+ return count > 0;
41
+ },
42
+ expire: async (key: string, ttlSeconds: number): Promise<boolean> => {
43
+ const c = await getClient();
44
+ return c.expire(key, ttlSeconds);
45
+ },
46
+ hset: async (key: string, field: string, value: string): Promise<number> => {
47
+ const c = await getClient();
48
+ return c.hSet(key, field, value);
49
+ },
50
+ hget: async (key: string, field: string): Promise<string | null> => {
51
+ const c = await getClient();
52
+ const result = await c.hGet(key, field);
53
+ return result ?? null;
54
+ },
55
+ keys: async (pattern: string): Promise<string[]> => {
56
+ const c = await getClient();
57
+ return c.keys(pattern);
58
+ },
59
+ quit: async (): Promise<void> => {
60
+ if (client) {
61
+ await client.quit();
62
+ client = null;
63
+ }
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,3 @@
1
+ export interface RedisConfig {
2
+ url: string;
3
+ }
@@ -0,0 +1 @@
1
+ SQL_CONNECTION_STRING=
@@ -0,0 +1,49 @@
1
+ import "../load-env.js";
2
+ import { suite } from "../suite.js";
3
+ import { createSqlService } from "../services/sql.js";
4
+
5
+ type User = { id: number; name: string; email: string; };
6
+ type TransferResult = { status: string; };
7
+
8
+ const sql = createSqlService({
9
+ client: 'pg', // change to 'mysql2' or 'mssql' as needed
10
+ connection: process.env.SQL_CONNECTION_STRING!,
11
+ });
12
+
13
+ try {
14
+ await suite()
15
+ .step("query-users", async () => {
16
+ const users = await sql.query<User>(
17
+ "SELECT id, name, email FROM users WHERE active = ?",
18
+ [true]
19
+ );
20
+ console.log("active users:", users);
21
+ return users;
22
+ })
23
+ .step("insert-user", async () => {
24
+ const affected = await sql.execute(
25
+ "INSERT INTO users (name, email, active) VALUES (?, ?, ?)",
26
+ ["Alice", "alice@example.com", true]
27
+ );
28
+ console.log("rows inserted:", affected);
29
+ return affected;
30
+ })
31
+ .step("call-stored-proc", async () => {
32
+ const result = await sql.callProc<TransferResult>(
33
+ "usp_activate_user",
34
+ [42]
35
+ );
36
+ console.log("proc result:", result);
37
+ return result;
38
+ })
39
+ .step("transfer-in-transaction", async () => {
40
+ await sql.transaction(async (ops) => {
41
+ await ops.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
42
+ await ops.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
43
+ });
44
+ console.log("transfer complete");
45
+ })
46
+ .run("stopOnError");
47
+ } finally {
48
+ await sql.disconnect();
49
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "sql",
3
+ "description": "SQL -- query, execute, callProc, transaction across pg / mysql2 / mssql",
4
+ "dependencies": {
5
+ "knex": "^3.1.0",
6
+ "pg": "^8.13.0",
7
+ "mysql2": "^3.11.0",
8
+ "mssql": "^11.0.1"
9
+ },
10
+ "files": [
11
+ { "src": "service.ts", "dest": "src/services/sql.ts" },
12
+ { "src": "types.ts", "dest": "src/types/sql.ts" },
13
+ { "src": "service.test.ts", "dest": "src/services/sql.test.ts" }
14
+ ],
15
+ "env": "env.txt",
16
+ "example": "example.ts",
17
+ "transforms": {
18
+ "service.ts": [
19
+ { "from": "./types.js", "to": "../types/sql.js" }
20
+ ],
21
+ "service.test.ts": [
22
+ { "from": "./service.js", "to": "./sql.js" }
23
+ ]
24
+ },
25
+ "patches": {
26
+ "src/suite.ts": {
27
+ "imports": [
28
+ "import { createSqlService } from \"./services/sql.js\";",
29
+ "import type { SqlConfig } from \"./types/sql.js\";"
30
+ ],
31
+ "configs": " private sqlConfig?: SqlConfig;",
32
+ "methods": " sql(config: SqlConfig): this { this.sqlConfig = config; return this; }",
33
+ "services": " sql: createSqlService(this.sqlConfig),"
34
+ },
35
+ "src/types/suite.ts": {
36
+ "type-imports": "import type { SqlOps } from \"./sql.js\";",
37
+ "service-types": " sql: { query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>; execute(sql: string, params?: unknown[]): Promise<number>; callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]>; transaction<T>(fn: (ops: SqlOps) => Promise<T>): Promise<T>; disconnect(): Promise<void>; };"
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,224 @@
1
+ import { createSqlService } from './service.js';
2
+
3
+ jest.mock('knex');
4
+ import knexLib from 'knex';
5
+
6
+ const mockTrxRaw = jest.fn();
7
+ const mockTrx = { raw: mockTrxRaw };
8
+
9
+ const mockRaw = jest.fn();
10
+ const mockDestroy = jest.fn().mockResolvedValue(undefined);
11
+ const mockTransaction = jest.fn();
12
+
13
+ const mockDb = {
14
+ raw: mockRaw,
15
+ destroy: mockDestroy,
16
+ transaction: mockTransaction,
17
+ };
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ (knexLib as unknown as jest.Mock).mockReturnValue(mockDb);
22
+ mockDestroy.mockResolvedValue(undefined);
23
+ });
24
+
25
+ describe('createSqlService', () => {
26
+ describe('configuration', () => {
27
+ it('throws when any method is called without config', async () => {
28
+ const sql = createSqlService();
29
+ await expect(sql.query('SELECT 1')).rejects.toThrow('sql service is not configured');
30
+ });
31
+
32
+ it('creates a knex instance with the provided client and connection', async () => {
33
+ mockRaw.mockResolvedValue({ rows: [] });
34
+ const sql = createSqlService({ client: 'pg', connection: 'postgresql://localhost/test' });
35
+ await sql.query('SELECT 1');
36
+ expect(knexLib).toHaveBeenCalledWith({ client: 'pg', connection: 'postgresql://localhost/test' });
37
+ });
38
+
39
+ it('reuses the same knex instance across multiple calls', async () => {
40
+ mockRaw.mockResolvedValue({ rows: [] });
41
+ const sql = createSqlService({ client: 'pg', connection: 'postgresql://localhost/test' });
42
+ await sql.query('SELECT 1');
43
+ await sql.query('SELECT 2');
44
+ expect(knexLib).toHaveBeenCalledTimes(1);
45
+ });
46
+ });
47
+
48
+ describe('query', () => {
49
+ it('returns result.rows for pg', async () => {
50
+ mockRaw.mockResolvedValue({ rows: [{ id: 1 }] });
51
+ const sql = createSqlService({ client: 'pg', connection: '' });
52
+ const result = await sql.query('SELECT * FROM users');
53
+ expect(result).toEqual([{ id: 1 }]);
54
+ });
55
+
56
+ it('returns result[0] for mysql2', async () => {
57
+ mockRaw.mockResolvedValue([[{ id: 1 }], []]);
58
+ const sql = createSqlService({ client: 'mysql2', connection: '' });
59
+ const result = await sql.query('SELECT * FROM users');
60
+ expect(result).toEqual([{ id: 1 }]);
61
+ });
62
+
63
+ it('returns result[0] for mssql', async () => {
64
+ mockRaw.mockResolvedValue([[{ id: 1 }]]);
65
+ const sql = createSqlService({ client: 'mssql', connection: '' });
66
+ const result = await sql.query('SELECT * FROM users');
67
+ expect(result).toEqual([{ id: 1 }]);
68
+ });
69
+
70
+ it('passes sql and params to raw', async () => {
71
+ mockRaw.mockResolvedValue({ rows: [] });
72
+ const sql = createSqlService({ client: 'pg', connection: '' });
73
+ await sql.query('SELECT * FROM users WHERE id = ?', [42]);
74
+ expect(mockRaw).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', [42]);
75
+ });
76
+
77
+ it('defaults params to empty array when omitted', async () => {
78
+ mockRaw.mockResolvedValue({ rows: [] });
79
+ const sql = createSqlService({ client: 'pg', connection: '' });
80
+ await sql.query('SELECT 1');
81
+ expect(mockRaw).toHaveBeenCalledWith('SELECT 1', []);
82
+ });
83
+ });
84
+
85
+ describe('execute', () => {
86
+ it('returns rowCount for pg', async () => {
87
+ mockRaw.mockResolvedValue({ rowCount: 3 });
88
+ const sql = createSqlService({ client: 'pg', connection: '' });
89
+ const result = await sql.execute('DELETE FROM users WHERE active = ?', [false]);
90
+ expect(result).toBe(3);
91
+ });
92
+
93
+ it('returns affectedRows for mysql2', async () => {
94
+ mockRaw.mockResolvedValue([{ affectedRows: 2 }]);
95
+ const sql = createSqlService({ client: 'mysql2', connection: '' });
96
+ const result = await sql.execute('UPDATE users SET active = ?', [true]);
97
+ expect(result).toBe(2);
98
+ });
99
+
100
+ it('returns rowsAffected[0] for mssql', async () => {
101
+ mockRaw.mockResolvedValue({ rowsAffected: [5] });
102
+ const sql = createSqlService({ client: 'mssql', connection: '' });
103
+ const result = await sql.execute('INSERT INTO logs (msg) VALUES (?)', ['test']);
104
+ expect(result).toBe(5);
105
+ });
106
+
107
+ it('returns 0 when pg rowCount is null', async () => {
108
+ mockRaw.mockResolvedValue({ rowCount: null });
109
+ const sql = createSqlService({ client: 'pg', connection: '' });
110
+ const result = await sql.execute('SELECT 1');
111
+ expect(result).toBe(0);
112
+ });
113
+ });
114
+
115
+ describe('callProc', () => {
116
+ it('uses CALL syntax for pg', async () => {
117
+ mockRaw.mockResolvedValue({ rows: [] });
118
+ const sql = createSqlService({ client: 'pg', connection: '' });
119
+ await sql.callProc('my_proc', [1, 'test']);
120
+ expect(mockRaw).toHaveBeenCalledWith('CALL my_proc(?, ?)', [1, 'test']);
121
+ });
122
+
123
+ it('uses CALL syntax for mysql2', async () => {
124
+ mockRaw.mockResolvedValue([[[]], {}]);
125
+ const sql = createSqlService({ client: 'mysql2', connection: '' });
126
+ await sql.callProc('my_proc', [1]);
127
+ expect(mockRaw).toHaveBeenCalledWith('CALL my_proc(?)', [1]);
128
+ });
129
+
130
+ it('uses EXEC syntax for mssql', async () => {
131
+ mockRaw.mockResolvedValue([[{ result: 1 }]]);
132
+ const sql = createSqlService({ client: 'mssql', connection: '' });
133
+ await sql.callProc('usp_my_proc', [1, 2]);
134
+ expect(mockRaw).toHaveBeenCalledWith('EXEC usp_my_proc ?, ?', [1, 2]);
135
+ });
136
+
137
+ it('uses EXEC without params for mssql when no params given', async () => {
138
+ mockRaw.mockResolvedValue([[{ result: 1 }]]);
139
+ const sql = createSqlService({ client: 'mssql', connection: '' });
140
+ await sql.callProc('usp_my_proc');
141
+ expect(mockRaw).toHaveBeenCalledWith('EXEC usp_my_proc', []);
142
+ });
143
+
144
+ it('returns result.rows for pg', async () => {
145
+ mockRaw.mockResolvedValue({ rows: [{ status: 'ok' }] });
146
+ const sql = createSqlService({ client: 'pg', connection: '' });
147
+ const result = await sql.callProc('my_proc', []);
148
+ expect(result).toEqual([{ status: 'ok' }]);
149
+ });
150
+
151
+ it('returns result[0] for mssql', async () => {
152
+ mockRaw.mockResolvedValue([[{ status: 'ok' }]]);
153
+ const sql = createSqlService({ client: 'mssql', connection: '' });
154
+ const result = await sql.callProc('usp_my_proc', []);
155
+ expect(result).toEqual([{ status: 'ok' }]);
156
+ });
157
+
158
+ it('returns the first result set for mysql2', async () => {
159
+ mockRaw.mockResolvedValue([[[{ status: 'ok' }], {}], []]);
160
+ const sql = createSqlService({ client: 'mysql2', connection: '' });
161
+ const result = await sql.callProc('my_proc', []);
162
+ expect(result).toEqual([{ status: 'ok' }]);
163
+ });
164
+ });
165
+
166
+ describe('transaction', () => {
167
+ it('calls knex transaction and passes SqlOps to fn', async () => {
168
+ mockTrxRaw.mockResolvedValue({ rows: [{ id: 1 }] });
169
+ mockTransaction.mockImplementation((fn: (trx: typeof mockTrx) => Promise<unknown>) => fn(mockTrx));
170
+
171
+ const sql = createSqlService({ client: 'pg', connection: '' });
172
+ const result = await sql.transaction(async (ops) => ops.query('SELECT 1'));
173
+
174
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
175
+ expect(result).toEqual([{ id: 1 }]);
176
+ });
177
+
178
+ it('ops.execute within transaction uses the transaction executor', async () => {
179
+ mockTrxRaw.mockResolvedValue({ rowCount: 2 });
180
+ mockTransaction.mockImplementation((fn: (trx: typeof mockTrx) => Promise<unknown>) => fn(mockTrx));
181
+
182
+ const sql = createSqlService({ client: 'pg', connection: '' });
183
+ const affected = await sql.transaction(async (ops) => ops.execute('DELETE FROM tmp'));
184
+
185
+ expect(mockTrxRaw).toHaveBeenCalledWith('DELETE FROM tmp', []);
186
+ expect(affected).toBe(2);
187
+ });
188
+
189
+ it('ops.callProc within transaction uses the transaction executor', async () => {
190
+ mockTrxRaw.mockResolvedValue({ rows: [] });
191
+ mockTransaction.mockImplementation((fn: (trx: typeof mockTrx) => Promise<unknown>) => fn(mockTrx));
192
+
193
+ const sql = createSqlService({ client: 'pg', connection: '' });
194
+ await sql.transaction(async (ops) => ops.callProc('my_proc', [1]));
195
+
196
+ expect(mockTrxRaw).toHaveBeenCalledWith('CALL my_proc(?)', [1]);
197
+ });
198
+ });
199
+
200
+ describe('disconnect', () => {
201
+ it('destroys the knex instance and clears the reference', async () => {
202
+ mockRaw.mockResolvedValue({ rows: [] });
203
+ const sql = createSqlService({ client: 'pg', connection: '' });
204
+ await sql.query('SELECT 1');
205
+ await sql.disconnect();
206
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
207
+ });
208
+
209
+ it('is a no-op when never connected', async () => {
210
+ const sql = createSqlService({ client: 'pg', connection: '' });
211
+ await expect(sql.disconnect()).resolves.toBeUndefined();
212
+ expect(mockDestroy).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('creates a new knex instance after reconnecting following disconnect', async () => {
216
+ mockRaw.mockResolvedValue({ rows: [] });
217
+ const sql = createSqlService({ client: 'pg', connection: '' });
218
+ await sql.query('SELECT 1');
219
+ await sql.disconnect();
220
+ await sql.query('SELECT 1');
221
+ expect(knexLib).toHaveBeenCalledTimes(2);
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,87 @@
1
+ import knex, { Knex } from 'knex';
2
+ import type { SqlConfig, SqlOps } from './types.js';
3
+
4
+ export function createSqlService(config?: SqlConfig) {
5
+ const ensureConfig = () => {
6
+ if (!config) throw new Error("sql service is not configured -- call .sql(config) on the suite");
7
+ };
8
+
9
+ let db: Knex | null = null;
10
+
11
+ const getDb = (): Knex => {
12
+ ensureConfig();
13
+ if (!db) {
14
+ db = knex({ client: config!.client, connection: config!.connection });
15
+ }
16
+ return db;
17
+ };
18
+
19
+ const extractRows = <T>(result: unknown): T[] => {
20
+ if (config!.client === 'pg') return (result as { rows: T[] }).rows;
21
+ return ((result as unknown[])[0] as T[]) ?? [];
22
+ };
23
+
24
+ const extractAffected = (result: unknown): number => {
25
+ if (config!.client === 'pg') return (result as { rowCount: number }).rowCount ?? 0;
26
+ if (config!.client === 'mssql') return (result as { rowsAffected: number[] }).rowsAffected?.[0] ?? 0;
27
+ return ((result as [{ affectedRows: number }])[0]?.affectedRows) ?? 0;
28
+ };
29
+
30
+ const buildCallSql = (name: string, params: unknown[]): string => {
31
+ const placeholders = params.map(() => '?').join(', ');
32
+ return config!.client === 'mssql'
33
+ ? `EXEC ${name}${params.length ? ` ${placeholders}` : ''}`
34
+ : `CALL ${name}(${placeholders})`;
35
+ };
36
+
37
+ const extractProcRows = <T>(result: unknown): T[] => {
38
+ if (config!.client === 'pg') return (result as { rows: T[] }).rows;
39
+ if (config!.client === 'mssql') return ((result as unknown[])[0] as T[]) ?? [];
40
+ // mysql2 procs return [[resultSet, OkPacket], fields] -- first element of resultSet is the rows
41
+ const first = ((result as unknown[][])[0])?.[0];
42
+ return (Array.isArray(first) ? first : (result as unknown[])[0]) as T[];
43
+ };
44
+
45
+ const bind = (params?: unknown[]) => (params ?? []) as any[];
46
+
47
+ const makeOps = (executor: Knex | Knex.Transaction): SqlOps => ({
48
+ async query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
49
+ const result = await executor.raw(sql, bind(params));
50
+ return extractRows<T>(result);
51
+ },
52
+ async execute(sql: string, params?: unknown[]): Promise<number> {
53
+ const result = await executor.raw(sql, bind(params));
54
+ return extractAffected(result);
55
+ },
56
+ async callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]> {
57
+ const bound = bind(params);
58
+ const result = await executor.raw(buildCallSql(name, bound), bound);
59
+ return extractProcRows<T>(result);
60
+ },
61
+ });
62
+
63
+ return {
64
+ async query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
65
+ const result = await getDb().raw(sql, bind(params));
66
+ return extractRows<T>(result);
67
+ },
68
+ async execute(sql: string, params?: unknown[]): Promise<number> {
69
+ const result = await getDb().raw(sql, bind(params));
70
+ return extractAffected(result);
71
+ },
72
+ async callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]> {
73
+ const bound = bind(params);
74
+ const result = await getDb().raw(buildCallSql(name, bound), bound);
75
+ return extractProcRows<T>(result);
76
+ },
77
+ async transaction<T>(fn: (ops: SqlOps) => Promise<T>): Promise<T> {
78
+ return getDb().transaction(trx => fn(makeOps(trx)));
79
+ },
80
+ async disconnect(): Promise<void> {
81
+ if (db) {
82
+ await db.destroy();
83
+ db = null;
84
+ }
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,10 @@
1
+ export interface SqlConfig {
2
+ client: 'pg' | 'mysql2' | 'mssql';
3
+ connection: string | Record<string, unknown>;
4
+ }
5
+
6
+ export interface SqlOps {
7
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
8
+ execute(sql: string, params?: unknown[]): Promise<number>;
9
+ callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]>;
10
+ }