@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 +123 -0
- package/dist/add.js +2 -0
- package/package.json +3 -1
- package/templates/init/GUIDE.md +121 -0
- package/templates/init/README.md +1 -1
- package/templates/plugins/redis/env.txt +1 -0
- package/templates/plugins/redis/example.ts +37 -0
- package/templates/plugins/redis/plugin.json +36 -0
- package/templates/plugins/redis/service.test.ts +149 -0
- package/templates/plugins/redis/service.ts +66 -0
- package/templates/plugins/redis/types.ts +3 -0
- package/templates/plugins/sql/env.txt +1 -0
- package/templates/plugins/sql/example.ts +49 -0
- package/templates/plugins/sql/plugin.json +40 -0
- package/templates/plugins/sql/service.test.ts +224 -0
- package/templates/plugins/sql/service.ts +87 -0
- package/templates/plugins/sql/types.ts +10 -0
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jordanalec/dtk",
|
|
3
|
-
"version": "1.0
|
|
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"
|
package/templates/init/GUIDE.md
CHANGED
|
@@ -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.
|
package/templates/init/README.md
CHANGED
|
@@ -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 @@
|
|
|
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
|
+
}
|