@squiz/db-lib 1.2.1-alpha.100
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +80 -0
- package/README.md +4 -0
- package/build.js +33 -0
- package/jest.config.ts +14 -0
- package/lib/AbstractRepository.d.ts +43 -0
- package/lib/ConnectionManager.d.ts +25 -0
- package/lib/Migrator.d.ts +18 -0
- package/lib/PostgresErrorCodes.d.ts +268 -0
- package/lib/Repositories.d.ts +2 -0
- package/lib/getConnectionInfo.d.ts +5 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +31524 -0
- package/lib/index.js.map +7 -0
- package/package.json +37 -0
- package/src/AbstractRepository.ts +179 -0
- package/src/ConnectionManager.ts +86 -0
- package/src/Migrator.ts +143 -0
- package/src/PostgresErrorCodes.ts +270 -0
- package/src/Repositories.ts +3 -0
- package/src/getConnectionInfo.ts +37 -0
- package/src/index.ts +8 -0
- package/tsconfig.json +17 -0
- package/tsconfig.tsbuildinfo +1 -0
package/package.json
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
{
|
2
|
+
"name": "@squiz/db-lib",
|
3
|
+
"version": "1.2.1-alpha.100",
|
4
|
+
"description": "",
|
5
|
+
"main": "lib/index.js",
|
6
|
+
"scripts": {
|
7
|
+
"start": "node ./lib/index.js",
|
8
|
+
"compile": "node build.js && npx tsc",
|
9
|
+
"lint": "eslint ./src --ext .ts",
|
10
|
+
"test": "jest -c jest.config.ts --passWithNoTests",
|
11
|
+
"test:update-snapshots": "jest -c jest.config.ts --updateSnapshot",
|
12
|
+
"clean": "rimraf \".tsbuildinfo\" \"./lib\""
|
13
|
+
},
|
14
|
+
"author": "",
|
15
|
+
"license": "ISC",
|
16
|
+
"devDependencies": {
|
17
|
+
"@types/jest": "28.1.6",
|
18
|
+
"@types/node": "17.0.27",
|
19
|
+
"@types/pg": "8.6.5",
|
20
|
+
"esbuild": "0.15.3",
|
21
|
+
"eslint": "8.22.0",
|
22
|
+
"fs-extra": "10.1.0",
|
23
|
+
"jest": "28.1.3",
|
24
|
+
"rimraf": "3.0.2",
|
25
|
+
"ts-jest": "28.0.7",
|
26
|
+
"ts-loader": "9.3.1",
|
27
|
+
"ts-node": "10.9.1",
|
28
|
+
"typescript": "4.7.4"
|
29
|
+
},
|
30
|
+
"dependencies": {
|
31
|
+
"@aws-sdk/client-secrets-manager": "3.121.0",
|
32
|
+
"@squiz/dx-logger-lib": "^1.2.1-alpha.100",
|
33
|
+
"dotenv": "16.0.1",
|
34
|
+
"pg": "8.7.3"
|
35
|
+
},
|
36
|
+
"gitHead": "909ab48128b72579f5f1390b7f045a3a41958795"
|
37
|
+
}
|
@@ -0,0 +1,179 @@
|
|
1
|
+
import { PoolClient, Pool } from 'pg';
|
2
|
+
import { Repositories } from './Repositories';
|
3
|
+
|
4
|
+
export interface Reader<T> {
|
5
|
+
find(item: Partial<T>): Promise<T[]>;
|
6
|
+
findOne(id: string | Partial<T>): Promise<T | undefined>;
|
7
|
+
}
|
8
|
+
|
9
|
+
export interface Writer<T> {
|
10
|
+
create(value: Partial<T>): Promise<T>;
|
11
|
+
update(where: Partial<T>, newValue: Partial<T>): Promise<T[]>;
|
12
|
+
delete(where: Partial<T>): Promise<number>;
|
13
|
+
}
|
14
|
+
|
15
|
+
export type Repository<T> = Reader<T> & Writer<T>;
|
16
|
+
|
17
|
+
export abstract class AbstractRepository<T> implements Reader<T>, Writer<T> {
|
18
|
+
protected tableName: string;
|
19
|
+
|
20
|
+
/** object where the key is the model property name amd the value is sql column name */
|
21
|
+
protected modelPropertyToSqlColumn: { [key in keyof T]: string };
|
22
|
+
/** object where the key is the sql column name and the value is the model property name */
|
23
|
+
protected sqlColumnToModelProperty: { [key: string]: string };
|
24
|
+
|
25
|
+
constructor(
|
26
|
+
protected repositories: Repositories,
|
27
|
+
protected pool: Pool,
|
28
|
+
tableName: string,
|
29
|
+
mapping: { [key in keyof T]: string },
|
30
|
+
protected classRef: { new (data?: Record<string, unknown>): T },
|
31
|
+
) {
|
32
|
+
this.tableName = `"${tableName}"`;
|
33
|
+
|
34
|
+
this.modelPropertyToSqlColumn = mapping;
|
35
|
+
|
36
|
+
this.sqlColumnToModelProperty = Object.entries(mapping).reduce((prev, curr) => {
|
37
|
+
const [modelProp, columnName] = curr as [string, string];
|
38
|
+
prev[columnName] = modelProp;
|
39
|
+
return prev;
|
40
|
+
}, {} as { [key: string]: string });
|
41
|
+
}
|
42
|
+
|
43
|
+
protected async getConnection(): Promise<PoolClient> {
|
44
|
+
return await this.pool.connect();
|
45
|
+
}
|
46
|
+
|
47
|
+
async create(value: Partial<T>, transactionClient: PoolClient | null = null): Promise<T> {
|
48
|
+
const columns = Object.keys(value)
|
49
|
+
.map((a) => `"${this.modelPropertyToSqlColumn[a as keyof T]}"`)
|
50
|
+
.join(', ');
|
51
|
+
|
52
|
+
const values = Object.values(value)
|
53
|
+
.map((a, index) => `$${index + 1}`)
|
54
|
+
.join(', ');
|
55
|
+
|
56
|
+
const result = await this.executeQuery(
|
57
|
+
`INSERT INTO ${this.tableName} (${columns}) VALUES (${values}) RETURNING *`,
|
58
|
+
Object.values(value),
|
59
|
+
transactionClient,
|
60
|
+
);
|
61
|
+
|
62
|
+
return result[0];
|
63
|
+
}
|
64
|
+
|
65
|
+
async update(where: Partial<T>, newValue: Partial<T>, transactionClient: PoolClient | null = null): Promise<T[]> {
|
66
|
+
const whereValues = Object.values(where);
|
67
|
+
const newValues = Object.values(newValue);
|
68
|
+
|
69
|
+
const setString = Object.keys(newValue)
|
70
|
+
.map((a, index) => `"${this.modelPropertyToSqlColumn[a as keyof T]}" = $${index + 1}`)
|
71
|
+
.join(', ');
|
72
|
+
|
73
|
+
const whereString = this.createWhereStringFromPartialModel(where, newValues.length);
|
74
|
+
|
75
|
+
const result = await this.executeQuery(
|
76
|
+
`UPDATE ${this.tableName}
|
77
|
+
SET ${setString}
|
78
|
+
WHERE ${whereString}
|
79
|
+
RETURNING *`,
|
80
|
+
[...Object.values(newValues), ...Object.values(whereValues)],
|
81
|
+
transactionClient,
|
82
|
+
);
|
83
|
+
|
84
|
+
return result;
|
85
|
+
}
|
86
|
+
|
87
|
+
async delete(where: Partial<T>, transactionClient: PoolClient | null = null): Promise<number> {
|
88
|
+
const client = transactionClient ?? (await this.getConnection());
|
89
|
+
|
90
|
+
try {
|
91
|
+
const whereString = this.createWhereStringFromPartialModel(where);
|
92
|
+
|
93
|
+
const result = await client.query(`DELETE FROM ${this.tableName} WHERE ${whereString}`, Object.values(where));
|
94
|
+
|
95
|
+
return result.rowCount;
|
96
|
+
} finally {
|
97
|
+
if (client && !transactionClient) {
|
98
|
+
client.release();
|
99
|
+
}
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
103
|
+
protected createWhereStringFromPartialModel(values: Partial<T>, initialIndex: number = 0) {
|
104
|
+
const keys = Object.keys(values);
|
105
|
+
|
106
|
+
if (keys.length == 0) {
|
107
|
+
throw new Error(`Values cannot be an empty object. It must have at least one property`);
|
108
|
+
}
|
109
|
+
|
110
|
+
const sql = keys.reduce((acc, key, index) => {
|
111
|
+
const condition = `"${this.modelPropertyToSqlColumn[key as keyof T]}" = $${1 + index + initialIndex}`;
|
112
|
+
|
113
|
+
return acc === '' ? `${acc} ${condition}` : `${acc} AND ${condition}`;
|
114
|
+
}, '');
|
115
|
+
|
116
|
+
return sql;
|
117
|
+
}
|
118
|
+
|
119
|
+
protected async executeQuery(
|
120
|
+
query: string,
|
121
|
+
values: any[],
|
122
|
+
transactionClient: PoolClient | null = null,
|
123
|
+
): Promise<T[]> {
|
124
|
+
const client = transactionClient ?? (await this.getConnection());
|
125
|
+
try {
|
126
|
+
const result = await client.query(query, values);
|
127
|
+
|
128
|
+
return result.rows.map((a) => this.createAndHydrateModel(a));
|
129
|
+
} finally {
|
130
|
+
if (client && !transactionClient) {
|
131
|
+
client.release();
|
132
|
+
}
|
133
|
+
}
|
134
|
+
}
|
135
|
+
|
136
|
+
protected createAndHydrateModel(row: any): T {
|
137
|
+
const inputData: Record<string, unknown> = {};
|
138
|
+
|
139
|
+
for (const key of Object.keys(row)) {
|
140
|
+
const translatedKey = this.sqlColumnToModelProperty[key];
|
141
|
+
inputData[translatedKey] = row[key];
|
142
|
+
}
|
143
|
+
|
144
|
+
return new this.classRef(inputData);
|
145
|
+
}
|
146
|
+
|
147
|
+
async findOne(item: Partial<T>): Promise<T | undefined> {
|
148
|
+
const result = await this.executeQuery(
|
149
|
+
`SELECT *
|
150
|
+
FROM ${this.tableName}
|
151
|
+
WHERE ${this.createWhereStringFromPartialModel(item)}
|
152
|
+
LIMIT 1`,
|
153
|
+
Object.values(item),
|
154
|
+
);
|
155
|
+
|
156
|
+
return result[0];
|
157
|
+
}
|
158
|
+
|
159
|
+
async find(item: Partial<T>): Promise<T[]> {
|
160
|
+
const result = await this.executeQuery(
|
161
|
+
`SELECT *
|
162
|
+
FROM ${this.tableName}
|
163
|
+
WHERE ${this.createWhereStringFromPartialModel(item)}`,
|
164
|
+
Object.values(item),
|
165
|
+
);
|
166
|
+
|
167
|
+
return result;
|
168
|
+
}
|
169
|
+
|
170
|
+
async findAll(): Promise<T[]> {
|
171
|
+
const result = await this.executeQuery(
|
172
|
+
`SELECT *
|
173
|
+
FROM ${this.tableName}`,
|
174
|
+
[],
|
175
|
+
);
|
176
|
+
|
177
|
+
return result;
|
178
|
+
}
|
179
|
+
}
|
@@ -0,0 +1,86 @@
|
|
1
|
+
import { Pool } from 'pg';
|
2
|
+
|
3
|
+
import os from 'os';
|
4
|
+
import { Migrator } from './Migrator';
|
5
|
+
|
6
|
+
import { PoolClient } from 'pg';
|
7
|
+
import { Repositories } from './Repositories';
|
8
|
+
|
9
|
+
export interface DbConnection {
|
10
|
+
user: string;
|
11
|
+
password: string;
|
12
|
+
host: string;
|
13
|
+
port: number;
|
14
|
+
database: string;
|
15
|
+
}
|
16
|
+
|
17
|
+
export interface ConnectionStringObj {
|
18
|
+
connectionString: string;
|
19
|
+
}
|
20
|
+
|
21
|
+
export type TransactionClient = PoolClient;
|
22
|
+
|
23
|
+
export class ConnectionManager<T extends Repositories> {
|
24
|
+
public readonly pool: Pool;
|
25
|
+
public readonly repositories: T;
|
26
|
+
|
27
|
+
constructor(
|
28
|
+
protected applicationName: string,
|
29
|
+
connection: string | DbConnection,
|
30
|
+
protected migrationDirectory: string,
|
31
|
+
protected migrationList: string[],
|
32
|
+
repositoryCreator: (dbManager: ConnectionManager<T>) => T,
|
33
|
+
) {
|
34
|
+
let connectionInfo: ConnectionStringObj | DbConnection;
|
35
|
+
|
36
|
+
if (typeof connection === 'string') {
|
37
|
+
connectionInfo = { connectionString: connection };
|
38
|
+
} else {
|
39
|
+
connectionInfo = connection;
|
40
|
+
}
|
41
|
+
|
42
|
+
this.pool = new Pool({
|
43
|
+
...connectionInfo,
|
44
|
+
|
45
|
+
application_name: applicationName,
|
46
|
+
|
47
|
+
query_timeout: 5000, // TODO consider
|
48
|
+
idleTimeoutMillis: 0, // TODO consider
|
49
|
+
connectionTimeoutMillis: 2000,
|
50
|
+
|
51
|
+
max: os.cpus().length * 2,
|
52
|
+
});
|
53
|
+
|
54
|
+
this.repositories = repositoryCreator(this);
|
55
|
+
}
|
56
|
+
|
57
|
+
public async applyMigrations() {
|
58
|
+
const connection = await this.pool.connect();
|
59
|
+
|
60
|
+
const migrator = new Migrator(this.migrationDirectory, this.migrationList, connection);
|
61
|
+
await migrator.migrate();
|
62
|
+
}
|
63
|
+
|
64
|
+
public async close() {
|
65
|
+
await this.pool.end();
|
66
|
+
this.pool.removeAllListeners();
|
67
|
+
}
|
68
|
+
|
69
|
+
public async executeInTransaction<T>(func: (client: TransactionClient) => Promise<T>): Promise<T> {
|
70
|
+
const client = await this.pool.connect();
|
71
|
+
|
72
|
+
try {
|
73
|
+
await client.query('BEGIN');
|
74
|
+
const value = await func(client);
|
75
|
+
await client.query('COMMIT');
|
76
|
+
return value;
|
77
|
+
} catch (e) {
|
78
|
+
await client.query('ROLLBACK');
|
79
|
+
throw e;
|
80
|
+
} finally {
|
81
|
+
if (client) {
|
82
|
+
client.release();
|
83
|
+
}
|
84
|
+
}
|
85
|
+
}
|
86
|
+
}
|
package/src/Migrator.ts
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
import fs from 'fs/promises';
|
2
|
+
import { PoolClient } from 'pg';
|
3
|
+
import path from 'path';
|
4
|
+
import os from 'os';
|
5
|
+
import { getLogger } from '@squiz/dx-logger-lib';
|
6
|
+
|
7
|
+
// Be carful about changing this value. This needs to be consistent across deployments
|
8
|
+
// this ID is used to signal other running instances that a migration is executing.
|
9
|
+
const MIGRATION_ADVISORY_LOCK = 4569465;
|
10
|
+
|
11
|
+
const logger = getLogger({ name: 'db-migrator', meta: { pid: process.pid, hostname: os.hostname() } });
|
12
|
+
|
13
|
+
export class Migrator {
|
14
|
+
constructor(protected migrationDir: string, protected migrationList: string[], protected pool: PoolClient) {}
|
15
|
+
|
16
|
+
protected async ensureMigrationTableExists() {
|
17
|
+
return this.pool.query('create table if not exists "__migrations__" (id varchar(128) NOT NULL)');
|
18
|
+
}
|
19
|
+
|
20
|
+
protected async getAppliedMigrations() {
|
21
|
+
await this.ensureMigrationTableExists();
|
22
|
+
const result = await this.pool.query('select * from __migrations__');
|
23
|
+
|
24
|
+
return result.rows.map(function (row) {
|
25
|
+
return row.id;
|
26
|
+
});
|
27
|
+
}
|
28
|
+
|
29
|
+
protected async applyMigration(migration: string, sql: string) {
|
30
|
+
try {
|
31
|
+
const result = await this.pool.query(sql);
|
32
|
+
logger.info('Applying ' + migration);
|
33
|
+
|
34
|
+
if (result.rowCount !== undefined) {
|
35
|
+
logger.info('affected rows', result.rowCount);
|
36
|
+
}
|
37
|
+
|
38
|
+
await this.pool.query('insert into __migrations__ (id) values ($1)', [migration]);
|
39
|
+
} catch (e) {
|
40
|
+
logger.info('error occurred running migration', migration, e);
|
41
|
+
throw e;
|
42
|
+
}
|
43
|
+
}
|
44
|
+
|
45
|
+
protected async getPending(migrationsList: string[], appliedMigrations: string[]) {
|
46
|
+
const pending: string[] = [];
|
47
|
+
|
48
|
+
// get all migrations
|
49
|
+
for (let i = 0; i < migrationsList.length; i++) {
|
50
|
+
if (migrationsList[i] !== appliedMigrations[i]) {
|
51
|
+
pending.push(migrationsList[i]);
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
55
|
+
// validate order
|
56
|
+
for (let i = 0; i < pending.length; i++) {
|
57
|
+
if (appliedMigrations.includes(pending[i])) {
|
58
|
+
throw new Error(`${pending[i]} has already run. Are you sure your migrations are in the correct order.`);
|
59
|
+
}
|
60
|
+
}
|
61
|
+
|
62
|
+
return pending;
|
63
|
+
}
|
64
|
+
|
65
|
+
protected async getSql(migration: string) {
|
66
|
+
const sql = await fs.readFile(path.join(this.migrationDir, migration), { encoding: 'utf-8' });
|
67
|
+
|
68
|
+
return sql;
|
69
|
+
}
|
70
|
+
|
71
|
+
protected async tryToObtainLock(): Promise<boolean> {
|
72
|
+
const result = await this.pool.query(`SELECT pg_try_advisory_lock(${MIGRATION_ADVISORY_LOCK}) as lockobtained`);
|
73
|
+
|
74
|
+
return result.rows[0].lockobtained;
|
75
|
+
}
|
76
|
+
|
77
|
+
protected async releaseLock(): Promise<void> {
|
78
|
+
await this.pool.query(`SELECT pg_advisory_unlock(${MIGRATION_ADVISORY_LOCK}) lockreleased`);
|
79
|
+
}
|
80
|
+
|
81
|
+
public async migrate(): Promise<any> {
|
82
|
+
try {
|
83
|
+
const lockObtained = await this.tryToObtainLock();
|
84
|
+
|
85
|
+
if (lockObtained === false) {
|
86
|
+
logger.info('migration already running');
|
87
|
+
await sleep(500);
|
88
|
+
return await this.migrate();
|
89
|
+
}
|
90
|
+
|
91
|
+
await this.runMigrations();
|
92
|
+
logger.info('completed migration');
|
93
|
+
await this.releaseLock();
|
94
|
+
|
95
|
+
this.dispose();
|
96
|
+
} catch (e) {
|
97
|
+
logger.info('migration failed releasing lock');
|
98
|
+
await this.releaseLock();
|
99
|
+
throw e;
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
103
|
+
protected async runMigrations() {
|
104
|
+
const appliedMigrations = await this.getAppliedMigrations();
|
105
|
+
const pending = await this.getPending(this.migrationList, appliedMigrations);
|
106
|
+
|
107
|
+
if (pending.length === 0) {
|
108
|
+
logger.info('No pending migrations');
|
109
|
+
return;
|
110
|
+
}
|
111
|
+
|
112
|
+
logger.info('Pending migrations:\n\t', pending.join('\n\t'));
|
113
|
+
|
114
|
+
for (const migration of pending) {
|
115
|
+
await this.runMigration(migration);
|
116
|
+
}
|
117
|
+
}
|
118
|
+
|
119
|
+
protected async runMigration(migration: string) {
|
120
|
+
try {
|
121
|
+
await this.pool.query('BEGIN');
|
122
|
+
|
123
|
+
const sql = await this.getSql(migration);
|
124
|
+
await this.applyMigration(migration, sql);
|
125
|
+
|
126
|
+
await this.pool.query('COMMIT');
|
127
|
+
} catch (e) {
|
128
|
+
logger.error('migration failed', migration, e);
|
129
|
+
await this.pool.query('ROLLBACK');
|
130
|
+
throw e;
|
131
|
+
}
|
132
|
+
}
|
133
|
+
|
134
|
+
protected dispose() {
|
135
|
+
return this.pool.release();
|
136
|
+
}
|
137
|
+
}
|
138
|
+
|
139
|
+
async function sleep(timeMs: number): Promise<void> {
|
140
|
+
return new Promise((resolve) => {
|
141
|
+
setTimeout(resolve, timeMs);
|
142
|
+
});
|
143
|
+
}
|