@squiz/db-lib 1.2.1-alpha.100

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