@devopsplaybook.io/common-utils 1.0.0-beta.5.18ca754
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/.github/workflows/main-build.yml +17 -0
- package/.github/workflows/npm-upgrade.yml +16 -0
- package/.github/workflows/pr-check.yml +26 -0
- package/.github/workflows/reusable-merge-build.yml +141 -0
- package/.github/workflows/reusable-npm-merge.yml +135 -0
- package/.github/workflows/reusable-npm-pr.yml +153 -0
- package/.github/workflows/reusable-npm-upgrade.yml +92 -0
- package/.github/workflows/reusable-pr-verify.yml +135 -0
- package/AGENTS.md +84 -0
- package/README.md +413 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +24 -0
- package/dist/src/ConfigBase.d.ts +104 -0
- package/dist/src/ConfigBase.js +201 -0
- package/dist/src/DbUtils.d.ts +50 -0
- package/dist/src/DbUtils.js +117 -0
- package/dist/src/DbUtilsNoTelemetry.d.ts +27 -0
- package/dist/src/DbUtilsNoTelemetry.js +89 -0
- package/dist/src/OTelContext.d.ts +35 -0
- package/dist/src/OTelContext.js +42 -0
- package/dist/src/PostgresDbUtils.d.ts +52 -0
- package/dist/src/PostgresDbUtils.js +217 -0
- package/dist/src/SqlDbUtils.d.ts +40 -0
- package/dist/src/SqlDbUtils.js +156 -0
- package/dist/src/SystemCommand.d.ts +9 -0
- package/dist/src/SystemCommand.js +56 -0
- package/dist/src/Timeout.d.ts +6 -0
- package/dist/src/Timeout.js +15 -0
- package/eslint.config.mjs +10 -0
- package/index.ts +8 -0
- package/jest.config.js +13 -0
- package/package.json +50 -0
- package/prettierrc.json +5 -0
- package/src/ConfigBase.spec.ts +108 -0
- package/src/ConfigBase.ts +213 -0
- package/src/DbUtils.spec.ts +23 -0
- package/src/DbUtils.ts +118 -0
- package/src/DbUtilsNoTelemetry.spec.ts +174 -0
- package/src/DbUtilsNoTelemetry.ts +121 -0
- package/src/OTelContext.spec.ts +58 -0
- package/src/OTelContext.ts +65 -0
- package/src/PostgresDbUtils.spec.ts +155 -0
- package/src/PostgresDbUtils.ts +233 -0
- package/src/SqlDbUtils.spec.ts +111 -0
- package/src/SqlDbUtils.ts +153 -0
- package/src/SystemCommand.spec.ts +18 -0
- package/src/SystemCommand.ts +23 -0
- package/src/Timeout.spec.ts +18 -0
- package/src/Timeout.ts +12 -0
- package/tsconfig.json +14 -0
- package/tsconfig.spec.json +7 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fse from "fs-extra";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
|
|
5
|
+
jest.mock("uuid", () => ({
|
|
6
|
+
v4: () => "mock-uuid-1234",
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { ConfigBase } from "./ConfigBase";
|
|
10
|
+
|
|
11
|
+
/** Concrete subclass for testing. */
|
|
12
|
+
class TestConfig extends ConfigBase {
|
|
13
|
+
public MY_SETTING = "default_value";
|
|
14
|
+
public SECRET_KEY = "secret_default";
|
|
15
|
+
|
|
16
|
+
constructor(configFile?: string) {
|
|
17
|
+
super("test-service", configFile);
|
|
18
|
+
this.addConfigField({ field: "MY_SETTING" });
|
|
19
|
+
this.addConfigField({ field: "SECRET_KEY", sensitive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("ConfigBase", () => {
|
|
24
|
+
let tmpDir: string;
|
|
25
|
+
let configPath: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tmpDir = fse.mkdtempSync(path.join(os.tmpdir(), "config-test-"));
|
|
29
|
+
configPath = path.join(tmpDir, "config.json");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fse.removeSync(tmpDir);
|
|
34
|
+
// Clean up env vars that tests may have set
|
|
35
|
+
delete process.env.MY_SETTING;
|
|
36
|
+
delete process.env.SECRET_KEY;
|
|
37
|
+
delete process.env.LOG_LEVEL;
|
|
38
|
+
delete process.env.DATA_DIR;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should initialise with default values", () => {
|
|
42
|
+
const config = new TestConfig(configPath);
|
|
43
|
+
expect(config.SERVICE_ID).toBe("test-service");
|
|
44
|
+
expect(config.MY_SETTING).toBe("default_value");
|
|
45
|
+
expect(config.SECRET_KEY).toBe("secret_default");
|
|
46
|
+
expect(config.LOG_LEVEL).toBe("info");
|
|
47
|
+
expect(config.DATABASE_TYPE).toBe("sqlite");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should load values from config.json", async () => {
|
|
51
|
+
fse.writeJsonSync(configPath, {
|
|
52
|
+
MY_SETTING: "from_file",
|
|
53
|
+
LOG_LEVEL: "debug",
|
|
54
|
+
});
|
|
55
|
+
const config = new TestConfig(configPath);
|
|
56
|
+
await config.reload();
|
|
57
|
+
expect(config.MY_SETTING).toBe("from_file");
|
|
58
|
+
expect(config.LOG_LEVEL).toBe("debug");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should prefer environment variables over config.json", async () => {
|
|
62
|
+
fse.writeJsonSync(configPath, {
|
|
63
|
+
MY_SETTING: "from_file",
|
|
64
|
+
});
|
|
65
|
+
process.env.MY_SETTING = "from_env";
|
|
66
|
+
const config = new TestConfig(configPath);
|
|
67
|
+
await config.reload();
|
|
68
|
+
expect(config.MY_SETTING).toBe("from_env");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should mask sensitive fields in log output", async () => {
|
|
72
|
+
const logs: string[] = [];
|
|
73
|
+
const config = new TestConfig(configPath);
|
|
74
|
+
await config.reload((msg) => logs.push(msg));
|
|
75
|
+
const secretLog = logs.find((l) => l.includes("SECRET_KEY"));
|
|
76
|
+
expect(secretLog).toContain("********************");
|
|
77
|
+
expect(secretLog).not.toContain("secret_default");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should not mask non-sensitive fields in log output", async () => {
|
|
81
|
+
const logs: string[] = [];
|
|
82
|
+
const config = new TestConfig(configPath);
|
|
83
|
+
await config.reload((msg) => logs.push(msg));
|
|
84
|
+
const settingLog = logs.find((l) => l.includes("MY_SETTING"));
|
|
85
|
+
expect(settingLog).toContain("default_value");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should survive missing config.json gracefully", async () => {
|
|
89
|
+
const config = new TestConfig("/nonexistent/path/config.json");
|
|
90
|
+
await config.reload();
|
|
91
|
+
// Should keep defaults
|
|
92
|
+
expect(config.MY_SETTING).toBe("default_value");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should register additional fields via addConfigField", async () => {
|
|
96
|
+
fse.writeJsonSync(configPath, { MY_SETTING: "updated" });
|
|
97
|
+
const config = new TestConfig(configPath);
|
|
98
|
+
await config.reload();
|
|
99
|
+
expect(config.MY_SETTING).toBe("updated");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should handle DATABASE_TYPE field", async () => {
|
|
103
|
+
fse.writeJsonSync(configPath, { DATABASE_TYPE: "postgres" });
|
|
104
|
+
const config = new TestConfig(configPath);
|
|
105
|
+
await config.reload();
|
|
106
|
+
expect(config.DATABASE_TYPE).toBe("postgres");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { ConfigOTelInterface } from "@devopsplaybook.io/otel-utils";
|
|
2
|
+
import * as fse from "fs-extra";
|
|
3
|
+
import { v4 as uuidv4 } from "uuid";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration field descriptor used by {@link ConfigBase.addConfigField}.
|
|
8
|
+
*/
|
|
9
|
+
export interface ConfigFieldDef {
|
|
10
|
+
/** Property name on the config instance. */
|
|
11
|
+
field: string;
|
|
12
|
+
/** When `true` the value is masked in log output. */
|
|
13
|
+
sensitive?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Database-specific configuration fields shared by every project that
|
|
18
|
+
* supports both SQLite and PostgreSQL backends.
|
|
19
|
+
*/
|
|
20
|
+
export interface ConfigDatabaseInterface {
|
|
21
|
+
DATABASE_TYPE: "sqlite" | "postgres";
|
|
22
|
+
DATABASE_POSTGRES_HOST: string;
|
|
23
|
+
DATABASE_POSTGRES_PORT: number;
|
|
24
|
+
DATABASE_POSTGRES_USER: string;
|
|
25
|
+
DATABASE_POSTGRES_PASSWORD: string;
|
|
26
|
+
DATABASE_POSTGRES_DATABASE: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Common server configuration fields shared across projects.
|
|
31
|
+
*/
|
|
32
|
+
export interface ConfigCommonInterface
|
|
33
|
+
extends ConfigOTelInterface, ConfigDatabaseInterface {
|
|
34
|
+
CONFIG_FILE: string;
|
|
35
|
+
API_PORT: number;
|
|
36
|
+
JWT_VALIDITY_DURATION: number;
|
|
37
|
+
CORS_POLICY_ORIGIN: string;
|
|
38
|
+
DATA_DIR: string;
|
|
39
|
+
JWT_KEY: string;
|
|
40
|
+
LOG_LEVEL: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Abstract base class for project configuration.
|
|
45
|
+
*
|
|
46
|
+
* Implements the three-layer override strategy used across all
|
|
47
|
+
* devopsplaybook.io server projects:
|
|
48
|
+
* 1. **Environment variable** (highest priority)
|
|
49
|
+
* 2. **config.json** file value
|
|
50
|
+
* 3. **Default** declared on the class property
|
|
51
|
+
*
|
|
52
|
+
* Subclasses add project-specific fields and call {@link addConfigField}
|
|
53
|
+
* inside their constructor so that {@link reload} picks them up.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* class MyConfig extends ConfigBase {
|
|
58
|
+
* public MY_SETTING = "default";
|
|
59
|
+
* constructor() {
|
|
60
|
+
* super("my-service");
|
|
61
|
+
* this.addConfigField({ field: "MY_SETTING" });
|
|
62
|
+
* }
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export abstract class ConfigBase implements ConfigCommonInterface {
|
|
67
|
+
// -- OTel fields (ConfigOTelInterface) --
|
|
68
|
+
public SERVICE_ID: string;
|
|
69
|
+
public VERSION = "1";
|
|
70
|
+
public OPENTELEMETRY_COLLECTOR_HTTP_TRACES = "";
|
|
71
|
+
public OPENTELEMETRY_COLLECTOR_HTTP_METRICS = "";
|
|
72
|
+
public OPENTELEMETRY_COLLECTOR_HTTP_LOGS = "";
|
|
73
|
+
public OPENTELEMETRY_COLLECTOR_AWS = false;
|
|
74
|
+
public OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS = 60;
|
|
75
|
+
public OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS = 60;
|
|
76
|
+
public OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER = "";
|
|
77
|
+
|
|
78
|
+
// -- Common server fields --
|
|
79
|
+
public CONFIG_FILE: string;
|
|
80
|
+
public API_PORT = 8080;
|
|
81
|
+
public JWT_VALIDITY_DURATION = 3 * 31 * 24 * 3600;
|
|
82
|
+
public CORS_POLICY_ORIGIN = "";
|
|
83
|
+
public DATA_DIR = process.env.DATA_DIR || "/data";
|
|
84
|
+
public JWT_KEY: string = uuidv4();
|
|
85
|
+
public LOG_LEVEL = "info";
|
|
86
|
+
|
|
87
|
+
// -- Database fields --
|
|
88
|
+
public DATABASE_TYPE: "sqlite" | "postgres" = "sqlite";
|
|
89
|
+
public DATABASE_POSTGRES_HOST = "";
|
|
90
|
+
public DATABASE_POSTGRES_PORT = 5432;
|
|
91
|
+
public DATABASE_POSTGRES_USER = "";
|
|
92
|
+
public DATABASE_POSTGRES_PASSWORD = "";
|
|
93
|
+
public DATABASE_POSTGRES_DATABASE = "";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fields registered by subclasses (or the base) that {@link reload}
|
|
97
|
+
* should process. Common / DB / OTel fields are pre-registered.
|
|
98
|
+
*/
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
100
|
+
private _fields: { field: string; sensitive: boolean; defaultValue: any }[] =
|
|
101
|
+
[];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param serviceId Unique service identifier (e.g. `"cryptotrader-server"`).
|
|
105
|
+
* @param configFile Optional path to the JSON config file. Defaults to `"config.json"`.
|
|
106
|
+
*/
|
|
107
|
+
constructor(serviceId: string, configFile?: string) {
|
|
108
|
+
this.SERVICE_ID = serviceId;
|
|
109
|
+
this.CONFIG_FILE = configFile || process.env.CONFIG_FILE || "config.json";
|
|
110
|
+
|
|
111
|
+
// Auto-detect version from nearest package.json
|
|
112
|
+
try {
|
|
113
|
+
const pkg = fse.readJsonSync(path.resolve(__dirname, "../package.json"));
|
|
114
|
+
if (pkg && pkg.version) {
|
|
115
|
+
this.VERSION = pkg.version;
|
|
116
|
+
}
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
118
|
+
} catch (_e) {
|
|
119
|
+
// fallback to "1"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pre-register base + DB + OTel fields so reload() handles them
|
|
123
|
+
const baseFields: ConfigFieldDef[] = [
|
|
124
|
+
{ field: "JWT_VALIDITY_DURATION" },
|
|
125
|
+
{ field: "CORS_POLICY_ORIGIN" },
|
|
126
|
+
{ field: "DATA_DIR" },
|
|
127
|
+
{ field: "JWT_KEY", sensitive: true },
|
|
128
|
+
{ field: "LOG_LEVEL" },
|
|
129
|
+
{ field: "DATABASE_TYPE" },
|
|
130
|
+
{ field: "DATABASE_POSTGRES_HOST" },
|
|
131
|
+
{ field: "DATABASE_POSTGRES_PORT" },
|
|
132
|
+
{ field: "DATABASE_POSTGRES_USER" },
|
|
133
|
+
{ field: "DATABASE_POSTGRES_PASSWORD", sensitive: true },
|
|
134
|
+
{ field: "DATABASE_POSTGRES_DATABASE" },
|
|
135
|
+
{ field: "OPENTELEMETRY_COLLECTOR_HTTP_TRACES" },
|
|
136
|
+
{ field: "OPENTELEMETRY_COLLECTOR_HTTP_METRICS" },
|
|
137
|
+
{ field: "OPENTELEMETRY_COLLECTOR_HTTP_LOGS" },
|
|
138
|
+
{ field: "OPENTELEMETRY_COLLECTOR_AWS" },
|
|
139
|
+
{
|
|
140
|
+
field: "OPENTELEMETRY_COLLECTOR_EXPORT_LOGS_INTERVAL_SECONDS",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
field: "OPENTELEMETRY_COLLECTOR_EXPORT_METRICS_INTERVAL_SECONDS",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
field: "OPENTELEMETRY_COLLECT_AUTHORIZATION_HEADER",
|
|
147
|
+
sensitive: true,
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
for (const f of baseFields) {
|
|
151
|
+
this.addConfigField(f);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Register a configuration field so that {@link reload} processes it.
|
|
157
|
+
* Call this in your subclass constructor for every project-specific field.
|
|
158
|
+
*/
|
|
159
|
+
public addConfigField(def: ConfigFieldDef): void {
|
|
160
|
+
this._fields.push({
|
|
161
|
+
field: def.field,
|
|
162
|
+
sensitive: def.sensitive ?? false,
|
|
163
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
164
|
+
defaultValue: (this as any)[def.field],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Load (or reload) configuration from the JSON file and environment variables.
|
|
170
|
+
* Environment variables always take precedence over file values.
|
|
171
|
+
*
|
|
172
|
+
* @param logger Optional log callback `(message: string) => void`.
|
|
173
|
+
* When omitted nothing is logged (useful in tests).
|
|
174
|
+
*/
|
|
175
|
+
public async reload(logger?: (message: string) => void): Promise<void> {
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
177
|
+
const log = logger ?? (() => {});
|
|
178
|
+
let content: Record<string, unknown> = {};
|
|
179
|
+
try {
|
|
180
|
+
content = await fse.readJson(this.CONFIG_FILE);
|
|
181
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
182
|
+
} catch (_e) {
|
|
183
|
+
// config file is optional – fall back to env + defaults
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
log(`Configuration Value: CONFIG_FILE: ${this.CONFIG_FILE}`);
|
|
187
|
+
log(`Configuration Value: SERVICE_ID: ${this.SERVICE_ID}`);
|
|
188
|
+
log(`Configuration Value: VERSION: ${this.VERSION}`);
|
|
189
|
+
|
|
190
|
+
for (const { field, sensitive } of this._fields) {
|
|
191
|
+
let from = "defaults";
|
|
192
|
+
if (process.env[field] !== undefined) {
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
194
|
+
(this as any)[field] = process.env[field];
|
|
195
|
+
from = "environment";
|
|
196
|
+
} else if (content[field] !== undefined) {
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
198
|
+
(this as any)[field] = content[field];
|
|
199
|
+
from = "config";
|
|
200
|
+
}
|
|
201
|
+
if (sensitive) {
|
|
202
|
+
log(
|
|
203
|
+
`Configuration Value: ${field}: ******************** (from ${from})`,
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
log(
|
|
207
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
208
|
+
`Configuration Value: ${field}: ${(this as any)[field]} (from ${from})`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { convertToPostgresPlaceholders } from "./DbUtils";
|
|
2
|
+
|
|
3
|
+
describe("convertToPostgresPlaceholders", () => {
|
|
4
|
+
it("should convert single ?", () => {
|
|
5
|
+
expect(convertToPostgresPlaceholders("SELECT * FROM t WHERE id = ?")).toBe(
|
|
6
|
+
"SELECT * FROM t WHERE id = $1",
|
|
7
|
+
);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should convert multiple ? to $1, $2, ...", () => {
|
|
11
|
+
expect(
|
|
12
|
+
convertToPostgresPlaceholders("INSERT INTO t (a,b,c) VALUES (?,?,?)"),
|
|
13
|
+
).toBe("INSERT INTO t (a,b,c) VALUES ($1,$2,$3)");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should return SQL unchanged when no ? present", () => {
|
|
17
|
+
expect(convertToPostgresPlaceholders("SELECT 1")).toBe("SELECT 1");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should handle empty string", () => {
|
|
21
|
+
expect(convertToPostgresPlaceholders("")).toBe("");
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/DbUtils.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Span } from "@opentelemetry/sdk-trace-base";
|
|
2
|
+
import { StandardTracer, StandardLogger } from "@devopsplaybook.io/otel-utils";
|
|
3
|
+
import * as SqlDbUtils from "./SqlDbUtils";
|
|
4
|
+
import * as PostgresDbUtils from "./PostgresDbUtils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration subset required by the unified DB facade.
|
|
8
|
+
*/
|
|
9
|
+
export interface DbUtilsConfig
|
|
10
|
+
extends SqlDbUtils.SqlDbConfig, PostgresDbUtils.PostgresDbConfig {
|
|
11
|
+
DATABASE_TYPE: "sqlite" | "postgres";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let databaseType: "sqlite" | "postgres" = "sqlite";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Injects the OTel tracer and logger instances used by the DB layer.
|
|
18
|
+
* Must be called once at startup, before {@link DbUtilsInit}.
|
|
19
|
+
*/
|
|
20
|
+
export function DbUtilsSetOTel(
|
|
21
|
+
tracer: StandardTracer,
|
|
22
|
+
logger: StandardLogger,
|
|
23
|
+
): void {
|
|
24
|
+
SqlDbUtils.SqlDbUtilsSetOTel(tracer, logger);
|
|
25
|
+
PostgresDbUtils.PostgresDbUtilsSetOTel(tracer, logger);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialise the database layer.
|
|
30
|
+
*
|
|
31
|
+
* Dispatches to the SQLite or Postgres backend depending on
|
|
32
|
+
* `config.DATABASE_TYPE` and runs pending migration files from `sqlDir`.
|
|
33
|
+
*
|
|
34
|
+
* @param context Parent OTel span.
|
|
35
|
+
* @param config Server configuration.
|
|
36
|
+
* @param sqlDir Absolute path to the directory containing SQL migration files.
|
|
37
|
+
*/
|
|
38
|
+
export async function DbUtilsInit(
|
|
39
|
+
context: Span,
|
|
40
|
+
config: DbUtilsConfig,
|
|
41
|
+
sqlDir: string,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
databaseType = config.DATABASE_TYPE;
|
|
44
|
+
if (databaseType === "postgres") {
|
|
45
|
+
await PostgresDbUtils.PostgresDbUtilsInit(context, config, sqlDir);
|
|
46
|
+
} else {
|
|
47
|
+
await SqlDbUtils.SqlDbUtilsInit(context, config, sqlDir);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns the native database handle.
|
|
53
|
+
* - SQLite: `better-sqlite3` `Database` instance
|
|
54
|
+
* - Postgres: `pg` `Pool` instance
|
|
55
|
+
*/
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
export function DbUtilsGetDatabase(): any {
|
|
58
|
+
if (databaseType === "postgres") {
|
|
59
|
+
return PostgresDbUtils.PostgresDbUtilsGetPool();
|
|
60
|
+
}
|
|
61
|
+
return SqlDbUtils.SqlDbUtilsGetDatabase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Convert SQLite `?` placeholders to PostgreSQL `$1, $2, ...` numbering. */
|
|
65
|
+
export function convertToPostgresPlaceholders(sql: string): string {
|
|
66
|
+
let paramIndex = 1;
|
|
67
|
+
return sql.replace(/\?/g, () => `$${paramIndex++}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Execute a write SQL statement with OTel tracing.
|
|
72
|
+
* Automatically converts `?` placeholders to `$N` when using Postgres.
|
|
73
|
+
*
|
|
74
|
+
* @returns Number of rows changed.
|
|
75
|
+
*/
|
|
76
|
+
export function DbUtilsExecSQL(
|
|
77
|
+
context: Span,
|
|
78
|
+
sql: string,
|
|
79
|
+
params: unknown[] = [],
|
|
80
|
+
): number | Promise<number> {
|
|
81
|
+
if (databaseType === "postgres") {
|
|
82
|
+
return PostgresDbUtils.PostgresDbUtilsExecSQL(
|
|
83
|
+
context,
|
|
84
|
+
convertToPostgresPlaceholders(sql),
|
|
85
|
+
params,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return SqlDbUtils.SqlDbUtilsExecSQL(context, sql, params);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Execute a read SQL query with OTel tracing.
|
|
93
|
+
* Automatically converts `?` placeholders to `$N` when using Postgres.
|
|
94
|
+
*
|
|
95
|
+
* @returns Array of row objects.
|
|
96
|
+
*/
|
|
97
|
+
export function DbUtilsQuerySQL(
|
|
98
|
+
context: Span,
|
|
99
|
+
sql: string,
|
|
100
|
+
params: unknown[] = [],
|
|
101
|
+
debug = false,
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
103
|
+
): any[] | Promise<any[]> {
|
|
104
|
+
if (databaseType === "postgres") {
|
|
105
|
+
return PostgresDbUtils.PostgresDbUtilsQuerySQL(
|
|
106
|
+
context,
|
|
107
|
+
convertToPostgresPlaceholders(sql),
|
|
108
|
+
params,
|
|
109
|
+
debug,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return SqlDbUtils.SqlDbUtilsQuerySQL(context, sql, params, debug);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Returns the active database type (`"sqlite"` or `"postgres"`). */
|
|
116
|
+
export function DbUtilsGetType(): "sqlite" | "postgres" {
|
|
117
|
+
return databaseType;
|
|
118
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DbUtilsNoTelemetryBatchInsert,
|
|
3
|
+
DbUtilsNoTelemetryExecSQL,
|
|
4
|
+
DbUtilsNoTelemetryQuerySQL,
|
|
5
|
+
} from "./DbUtilsNoTelemetry";
|
|
6
|
+
|
|
7
|
+
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
|
8
|
+
|
|
9
|
+
// Shared mock DB handle – defined BEFORE jest.mock factory so it's hoisted correctly.
|
|
10
|
+
// We use jest.fn() at module scope; the mock factory captures the same reference.
|
|
11
|
+
const mockPrepare = jest.fn(() => ({
|
|
12
|
+
run: jest.fn().mockReturnValue({ changes: 0 }),
|
|
13
|
+
all: jest.fn().mockReturnValue([]),
|
|
14
|
+
}));
|
|
15
|
+
const mockQuery = jest.fn();
|
|
16
|
+
const mockDbHandle = { prepare: mockPrepare, query: mockQuery };
|
|
17
|
+
|
|
18
|
+
let currentDbType: "sqlite" | "postgres" = "sqlite";
|
|
19
|
+
|
|
20
|
+
jest.mock("./DbUtils", () => ({
|
|
21
|
+
DbUtilsGetDatabase: jest.fn(() => mockDbHandle),
|
|
22
|
+
DbUtilsGetType: jest.fn(() => currentDbType),
|
|
23
|
+
convertToPostgresPlaceholders: jest.fn((sql: string) => {
|
|
24
|
+
let idx = 1;
|
|
25
|
+
return sql.replace(/\?/g, () => `$${idx++}`);
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock("@devopsplaybook.io/otel-utils", () => ({
|
|
30
|
+
ModuleLogger: jest.fn(),
|
|
31
|
+
StandardLogger: jest.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import * as DbUtilsNoTelemetryModule from "./DbUtilsNoTelemetry";
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
const mockLogger = {
|
|
38
|
+
error: jest.fn(),
|
|
39
|
+
info: jest.fn(),
|
|
40
|
+
warn: jest.fn(),
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
} as any;
|
|
43
|
+
DbUtilsNoTelemetryModule.DbUtilsNoTelemetrySetLogger({
|
|
44
|
+
createModuleLogger: () => mockLogger,
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
} as any);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.clearAllMocks();
|
|
51
|
+
currentDbType = "sqlite";
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("DbUtilsNoTelemetryBatchInsert", () => {
|
|
55
|
+
it("returns 0 for empty rows", () => {
|
|
56
|
+
const result = DbUtilsNoTelemetryBatchInsert("INTO t (c)", 1, []);
|
|
57
|
+
expect(result).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("generates correct multi-row VALUES SQL (sqlite)", () => {
|
|
61
|
+
currentDbType = "sqlite";
|
|
62
|
+
mockPrepare.mockReturnValue({
|
|
63
|
+
run: jest.fn().mockReturnValue({ changes: 2 }),
|
|
64
|
+
all: jest.fn(),
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
|
+
} as any);
|
|
67
|
+
|
|
68
|
+
const rows = [
|
|
69
|
+
["a1", "b1"],
|
|
70
|
+
["a2", "b2"],
|
|
71
|
+
];
|
|
72
|
+
const result = DbUtilsNoTelemetryBatchInsert("INTO t (c1,c2)", 2, rows);
|
|
73
|
+
expect(result).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("DbUtilsNoTelemetryExecSQL (sqlite)", () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
currentDbType = "sqlite";
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("resolves with changes count on success", () => {
|
|
83
|
+
mockPrepare.mockReturnValue({
|
|
84
|
+
run: jest.fn().mockReturnValue({ changes: 3 }),
|
|
85
|
+
all: jest.fn(),
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
} as any);
|
|
88
|
+
|
|
89
|
+
const result = DbUtilsNoTelemetryExecSQL("INSERT INTO t (c) VALUES (?)", [
|
|
90
|
+
"x",
|
|
91
|
+
]);
|
|
92
|
+
expect(result).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("DbUtilsNoTelemetryExecSQL (postgres)", () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
currentDbType = "postgres";
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("resolves with rowCount on success", async () => {
|
|
102
|
+
mockQuery.mockImplementation(
|
|
103
|
+
(_sql: string, _params: unknown[], cb: Function) => {
|
|
104
|
+
cb(null, { rowCount: 5 });
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const result = await DbUtilsNoTelemetryExecSQL(
|
|
109
|
+
"INSERT INTO t (c) VALUES (?)",
|
|
110
|
+
["x"],
|
|
111
|
+
);
|
|
112
|
+
expect(result).toBe(5);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rejects on error", async () => {
|
|
116
|
+
mockQuery.mockImplementation(
|
|
117
|
+
(_sql: string, _params: unknown[], cb: Function) => {
|
|
118
|
+
cb(new Error("deadlock detected"));
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
DbUtilsNoTelemetryExecSQL("INSERT INTO t (c) VALUES (?)", ["x"]),
|
|
124
|
+
).rejects.toThrow("deadlock detected");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("DbUtilsNoTelemetryQuerySQL (sqlite)", () => {
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
currentDbType = "sqlite";
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns rows on success", () => {
|
|
134
|
+
const expectedRows = [{ id: 1 }, { id: 2 }];
|
|
135
|
+
mockPrepare.mockReturnValue({
|
|
136
|
+
run: jest.fn(),
|
|
137
|
+
all: jest.fn().mockReturnValue(expectedRows),
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
139
|
+
} as any);
|
|
140
|
+
|
|
141
|
+
const result = DbUtilsNoTelemetryQuerySQL("SELECT * FROM t");
|
|
142
|
+
expect(result).toEqual(expectedRows);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("DbUtilsNoTelemetryQuerySQL (postgres)", () => {
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
currentDbType = "postgres";
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns rows on success", async () => {
|
|
152
|
+
const expectedRows = [{ id: 1 }, { id: 2 }];
|
|
153
|
+
mockQuery.mockImplementation(
|
|
154
|
+
(_sql: string, _params: unknown[], cb: Function) => {
|
|
155
|
+
cb(null, { rows: expectedRows });
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const result = await DbUtilsNoTelemetryQuerySQL("SELECT * FROM t");
|
|
160
|
+
expect(result).toEqual(expectedRows);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("rejects on error", async () => {
|
|
164
|
+
mockQuery.mockImplementation(
|
|
165
|
+
(_sql: string, _params: unknown[], cb: Function) => {
|
|
166
|
+
cb(new Error("connection lost"));
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await expect(DbUtilsNoTelemetryQuerySQL("SELECT * FROM t")).rejects.toThrow(
|
|
171
|
+
"connection lost",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|