@getanthill/datastore
Version:
Event-Sourced Datastore
229 lines (190 loc) • 6.05 kB
text/typescript
import type { ClientConfig, QueryResult } from 'pg';
import { Client } from 'pg';
import { ModelConfig } from '../typings';
import { cloneDeep, unset } from 'lodash';
export interface PostgreSQLClientConfig {
client: ClientConfig;
namespace: string;
}
export default class PostgreSQLClient {
static ERRORS = {};
private _config: PostgreSQLClientConfig;
private _client: Client;
constructor(config: PostgreSQLClientConfig) {
this._config = config;
this._client = new Client(config.client);
}
static async init(config: PostgreSQLClientConfig) {
const url = new URL(config.client.connectionString!);
const dbName: string = url.pathname.slice(1);
const client = new Client({
...config.client,
connectionString: config.client.connectionString!.replace(
`/${dbName}`,
'',
),
});
try {
await client.connect();
await client.query(`CREATE DATABASE ${dbName}`);
} catch (err) {
// ...
} finally {
await client.end();
}
}
connect() {
return this._client.connect();
}
disconnect() {
return this._client.end();
}
static getTableBaseName(
modelConfig: Partial<ModelConfig>,
namespace?: string,
): string {
const modelName = modelConfig.name;
return `${namespace ? namespace + '_' : ''}${modelName}`;
}
static getSqlSchemaForModelDeletion(
modelConfig: Partial<ModelConfig>,
namespace?: string,
): string[] {
const tableBaseName = PostgreSQLClient.getTableBaseName(
modelConfig,
namespace,
);
return [
`DROP TABLE IF EXISTS "${tableBaseName}";`,
`DROP TABLE IF EXISTS "${tableBaseName}_events";`,
`DROP TABLE IF EXISTS "${tableBaseName}_snapshots";`,
];
}
static getSqlSchemaForModel(
modelConfig: Partial<ModelConfig>,
namespace?: string,
): string[] {
const sql: string[] = [];
const correlationField = modelConfig.correlation_field;
const tableBaseName = PostgreSQLClient.getTableBaseName(
modelConfig,
namespace,
);
const modelDescription = modelConfig.description ?? '';
sql.push(`CREATE TABLE IF NOT EXISTS "${tableBaseName}" (
"${correlationField}" TEXT,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"version" BIGINT,
"entity" JSON,
PRIMARY KEY ("${correlationField}")
);`);
sql.push(`CREATE TABLE IF NOT EXISTS "${tableBaseName}_events" (
"${correlationField}" TEXT,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"version" BIGINT,
"type" VARCHAR(256),
"event" JSON,
PRIMARY KEY ("${correlationField}", "version")
);`);
sql.push(`CREATE TABLE IF NOT EXISTS "${tableBaseName}_snapshots" (
"${correlationField}" TEXT,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
"version" BIGINT,
"entity" JSON,
PRIMARY KEY ("${correlationField}")
);`);
sql.push(
`COMMENT ON TABLE "${tableBaseName}" IS ${
modelDescription ? "'[entities] " + modelDescription + "'" : 'NULL'
}`,
);
sql.push(
`COMMENT ON TABLE "${tableBaseName}_events" IS ${
modelDescription ? "'[events] " + modelDescription + "'" : 'NULL'
}`,
);
sql.push(
`COMMENT ON TABLE "${tableBaseName}_snapshots" IS ${
modelDescription ? "'[snapshots] " + modelDescription + "'" : 'NULL'
}`,
);
return sql;
}
static getSqlSchemaForModels(
modelConfigs: Partial<ModelConfig>[],
mustClean = false,
namespace?: string,
): string[] {
const sql: string[] = [];
for (const modelConfig of modelConfigs) {
sql.push(
...(mustClean === true
? PostgreSQLClient.getSqlSchemaForModelDeletion(
modelConfig,
namespace,
)
: []),
...PostgreSQLClient.getSqlSchemaForModel(modelConfig, namespace),
);
}
return sql;
}
query(q: string): Promise<QueryResult<any>> {
return this._client.query(q);
}
async queryAll(queries: string[]): Promise<QueryResult<any>[]> {
const results: QueryResult<any>[] = [];
for (const q of queries) {
const result = await this.query(q);
results.push(result);
}
return results;
}
insert(
modelConfig: Partial<ModelConfig>,
source: 'events' | 'entities',
data: any,
options?: {
with_encrypted_data: boolean;
},
): Promise<QueryResult<any>> {
let safeData = data;
if (options?.with_encrypted_data !== true) {
safeData = cloneDeep(data);
(modelConfig.encrypted_fields ?? []).forEach((path) =>
unset(safeData, `${path}.encrypted`),
);
}
const json = JSON.stringify(safeData).replace(/'/g, "''");
const tableBaseName = PostgreSQLClient.getTableBaseName(
modelConfig,
this._config.namespace,
);
const tableName = `${tableBaseName}${source === 'events' ? '_events' : ''}`;
const sql = `INSERT INTO "${tableName}" (
${modelConfig.correlation_field!},
created_at,${source === 'entities' ? ' updated_at,' : ''}
version, ${source === 'events' ? ' type,' : ''}
${source === 'events' ? 'event' : 'entity'}
) VALUES (
'${safeData[modelConfig.correlation_field!]}',
'${safeData.created_at}',${
source === 'entities' ? "'" + safeData.updated_at + "'," : ''
}
${safeData.version},${
source === 'events' ? "'" + safeData.type + "'," : ''
}
'${json}'
) ON CONFLICT ON CONSTRAINT ${tableName}_pkey DO UPDATE SET
"version" = ${safeData.version},
${
source === 'entities'
? '"updated_at" = \'' + safeData.updated_at + "',"
: '"created_at" = \'' + safeData.created_at + "',"
}
"${source === 'events' ? 'event' : 'entity'}" = '${json}'`;
return this.query(sql);
}
}