UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

200 lines (173 loc) 5.25 kB
import assert from 'node:assert' import { EventEmitter } from 'node:stream' import { Kysely, KyselyPlugin, Migrator, PluginTransformQueryArgs, PluginTransformResultArgs, PostgresDialect, QueryResult, RootOperationNode, UnknownRow, } from 'kysely' import { Pool as PgPool, types as pgTypes } from 'pg' import TypedEmitter from 'typed-emitter' import { dbLogger } from '../logger' import * as migrations from './migrations' import { CtxMigrationProvider } from './migrations/provider' import { DatabaseSchema, DatabaseSchemaType } from './schema' import { PgOptions } from './types' export class Database { pool: PgPool db: DatabaseSchema migrator: Migrator txEvt = new EventEmitter() as TxnEmitter destroyed = false isPrimary = false constructor( public opts: PgOptions, instances?: { db: DatabaseSchema; pool: PgPool }, ) { // if instances are provided, use those if (instances) { this.db = instances.db this.pool = instances.pool } else { // else create a pool & connect const { schema, url } = opts const pool = opts.pool ?? new PgPool({ connectionString: url, max: opts.poolSize, maxUses: opts.poolMaxUses, idleTimeoutMillis: opts.poolIdleTimeoutMs, }) // Select count(*) and other pg bigints as js integer pgTypes.setTypeParser(pgTypes.builtins.INT8, (n) => parseInt(n, 10)) // Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema) if (schema && !/^[a-z_]+$/i.test(schema)) { throw new Error( `Postgres schema must only contain [A-Za-z_]: ${schema}`, ) } pool.on('error', onPoolError) pool.on('connect', (client) => { client.on('error', onClientError) // Used for trigram indexes, e.g. on actor search client.query('SET pg_trgm.word_similarity_threshold TO .4;') if (schema) { // Shared objects such as extensions will go in the public schema client.query(`SET search_path TO "${schema}",public;`) } }) this.pool = pool this.db = new Kysely<DatabaseSchemaType>({ dialect: new PostgresDialect({ pool }), }) } this.migrator = new Migrator({ db: this.db, migrationTableSchema: opts.schema, provider: new CtxMigrationProvider(migrations, 'pg'), }) } get schema(): string | undefined { return this.opts.schema } get isTransaction() { return this.db.isTransaction } assertTransaction() { assert(this.isTransaction, 'Transaction required') } assertNotTransaction() { assert(!this.isTransaction, 'Cannot be in a transaction') } async transaction<T>(fn: (db: Database) => Promise<T>): Promise<T> { const leakyTxPlugin = new LeakyTxPlugin() const { dbTxn, txRes } = await this.db .withPlugin(leakyTxPlugin) .transaction() .execute(async (txn) => { const dbTxn = new Database(this.opts, { db: txn, pool: this.pool, }) const txRes = await fn(dbTxn) .catch(async (err) => { leakyTxPlugin.endTx() // ensure that all in-flight queries are flushed & the connection is open await dbTxn.db.getExecutor().provideConnection(noopAsync) throw err }) .finally(() => leakyTxPlugin.endTx()) return { dbTxn, txRes } }) dbTxn?.txEvt.emit('commit') return txRes } onCommit(fn: () => void) { this.assertTransaction() this.txEvt.once('commit', fn) } async close(): Promise<void> { if (this.destroyed) return await this.db.destroy() this.destroyed = true } async migrateToOrThrow(migration: string) { if (this.schema) { await this.db.schema.createSchema(this.schema).ifNotExists().execute() } const { error, results } = await this.migrator.migrateTo(migration) if (error) { throw error } if (!results) { throw new Error('An unknown failure occurred while migrating') } return results } async migrateToLatestOrThrow() { if (this.schema) { await this.db.schema.createSchema(this.schema).ifNotExists().execute() } const { error, results } = await this.migrator.migrateToLatest() if (error) { throw error } if (!results) { throw new Error('An unknown failure occurred while migrating') } return results } } export default Database const onPoolError = (err: Error) => dbLogger.error({ err }, 'db pool error') const onClientError = (err: Error) => dbLogger.error({ err }, 'db client error') // utils // ------- class LeakyTxPlugin implements KyselyPlugin { private txOver = false endTx() { this.txOver = true } transformQuery(args: PluginTransformQueryArgs): RootOperationNode { if (this.txOver) { throw new Error('tx already failed') } return args.node } async transformResult( args: PluginTransformResultArgs, ): Promise<QueryResult<UnknownRow>> { return args.result } } type TxnEmitter = TypedEmitter<TxnEvents> type TxnEvents = { commit: () => void } const noopAsync = async () => {}