@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/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
+ }
@@ -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
+ }