/* * Copyright (C) 2024-present Puter Technologies Inc. * * This file is part of Puter. * * Puter is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import { readdirSync, readFileSync } from 'fs'; import { isAbsolute, resolve as resolvePath } from 'path'; import type { IConfig } from '../../types'; import { Span } from '../../util/span.js'; import { PuterClient } from '../types'; import { compareMigrationFilenames } from './migrationFilenames.js'; export interface WriteResult { insertId: number | bigint; affectedRows: number; anyRowsAffected: boolean; /** * Warnings the statement raised; nonzero means a value may have been stored * differently from how it was sent. Absent when the engine can't vouch that * a clean insert stored exactly what was sent, on a fresh id. */ warningStatus?: number; } export interface BatchEntry { statement: string; values: unknown[]; } type SqlJsonPath = readonly [string, ...string[]]; /** The slice of a pooled connection that migrations need. */ export interface MigrationConnection { query(sql: string): Promise; release(): void; } /** * Base database client. Subclasses must override every method that throws here. * * Do not instantiate directly — use the factory exported from * `clients/database/index.ts` which picks the right implementation based on * `config.database.engine`. */ export class AbstractDatabaseClient extends PuterClient { /** Short name used by `case()` to pick engine-specific values. */ readonly engineName: string = ''; constructor(config: IConfig) { super(config); } // ------------------------------------------------------------------ // Abstract interface — subclasses MUST override // ------------------------------------------------------------------ /** Execute a read query. Returns an array of row objects. */ async read( _query: string, _params: unknown[] = [], ): Promise[]> { throw new Error('DatabaseClient.read() not implemented'); } /** * Read that prefers the primary database (useful when read-replicas may * have replication lag). In single-node setups this is identical to * `read()`. */ async pread( _query: string, _params: unknown[] = [], ): Promise[]> { throw new Error('DatabaseClient.pread() not implemented'); } /** Execute a write query (INSERT / UPDATE / DELETE). */ async write(_query: string, _params: unknown[] = []): Promise { throw new Error('DatabaseClient.write() not implemented'); } /** Execute multiple write statements in a single transaction. */ async batchWrite(_entries: BatchEntry[]): Promise { throw new Error('DatabaseClient.batchWrite() not implemented'); } // ------------------------------------------------------------------ // Shared helpers (rely on the abstract methods above) // ------------------------------------------------------------------ /** * Generate and execute an INSERT statement from a table name and a * key/value data object. */ async insert( tableName: string, data: Record, ): Promise { const cols = Object.keys(data); const values = Object.values(data); const sql = `INSERT INTO ${this.quoteIdentifier(tableName)} ` + `(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` + `VALUES (${cols.map(() => '?').join(', ')})` + this.returningIdClause(); return this.write(sql, values); } /** Whether `read()` goes to a separate replica rather than the primary. */ protected hasReadReplica(): boolean { return false; } /** * The replica leg of `tryHardRead`. Override when `read()` falls back to * the primary by itself, or a replica failure reads the primary twice. */ protected replicaRead( query: string, params: unknown[], ): Promise[]> { return this.read(query, params); } /** * Like `read()`, but an empty or failed replica read falls back to the * primary, so replication lag can't hide a row that was just written. By * default the primary read starts alongside the replica one; `race: false` * reads the primary only after the replica returns empty or fails. Without * a replica this is a single `read()`. */ @Span('db.tryHardRead', (query: string) => ({ 'db.statement': query })) async tryHardRead( query: string, params: unknown[] = [], { race = true }: { race?: boolean } = {}, ): Promise[]> { if (!this.hasReadReplica()) return this.read(query, params); const primary = race ? settle(this.pread(query, params)) : null; try { const rows = await this.replicaRead(query, params); if (rows.length > 0) return rows; } catch { // replica failed — fall through to primary } const result = await (primary ?? settle(this.pread(query, params))); if ('error' in result) throw result.error; return result.value; } /** * Apply the `.sql` files named `*` in each configured migration * directory, in filename order. Files must be idempotent: nothing records * which ones already ran. A failure aborts startup. `transactional` wraps * each file in BEGIN/COMMIT, for engines whose DDL can roll back. */ protected async applyMigrationPaths( connect: () => Promise, splitStatements: (contents: string) => string[], { transactional }: { transactional: boolean }, ): Promise { const paths = this.config.database?.migrationPaths; if (!paths || paths.length === 0) return; const tag = `[${this.engineName}]`; const conn = await connect(); try { for (const rawPath of paths) { const dir = isAbsolute(rawPath) ? rawPath : resolvePath(process.cwd(), rawPath); let files: string[]; try { files = readdirSync(dir) .filter( (f) => f.endsWith('.sql') && f.startsWith(this.engineName), ) .sort(compareMigrationFilenames); } catch (e) { throw new Error( `${tag} migration path is unreadable: ${dir}`, { cause: e, }, ); } if (files.length === 0) { console.log(`${tag} no migrations in ${dir}`); continue; } console.log( `${tag} running migrations from ${dir}: ${files.length} file(s)`, ); for (const file of files) { const contents = readFileSync( resolvePath(dir, file), 'utf8', ); const statements = splitStatements(contents); if (transactional) await conn.query('BEGIN'); try { for (let i = 0; i < statements.length; i++) { try { await conn.query(statements[i]); } catch (e) { throw new Error( `${tag} failed to apply ${file} at statement ${i}`, { cause: e }, ); } } if (transactional) await conn.query('COMMIT'); } catch (e) { if (transactional) { try { await conn.query('ROLLBACK'); } catch { // the original failure is the one worth surfacing } } throw e; } console.log( `${tag} applied ${file} (${statements.length} statements)`, ); } } } finally { conn.release(); } } /** * Return the value from `choices` that matches the current engine. * * Usage: * * db.case({ * sqlite: "datetime('now')", * mysql: 'NOW()', * otherwise: 'NOW()', * }); * * If the engine name isn't present in `choices`, falls back to * `choices.otherwise`. */ case(choices: Record & { otherwise?: T }): T { if (Object.prototype.hasOwnProperty.call(choices, this.engineName)) { return choices[this.engineName]; } return choices.otherwise as T; } quoteIdentifier(identifier: string): string { return identifier .split('.') .map((part) => { if (part === '*') return part; return `\`${part.replaceAll('`', '``')}\``; }) .join('.'); } booleanLiteral(value: boolean): string { return value ? '1' : '0'; } booleanValue(value: boolean): boolean | 0 | 1 { return value ? 1 : 0; } insertIgnoreInto(tableName: string): string { const table = this.quoteIdentifier(tableName); return this.case({ sqlite: `INSERT OR IGNORE INTO ${table}`, postgres: `INSERT INTO ${table}`, otherwise: `INSERT IGNORE INTO ${table}`, }); } insertIgnoreSuffix(): string { return this.case({ postgres: ' ON CONFLICT DO NOTHING', otherwise: '', }); } upsertClause( conflictColumns: readonly string[], updateColumns: readonly string[], ): string { if (updateColumns.length === 0) { throw new Error('upsertClause requires at least one update column'); } const updateList = updateColumns .map((column) => `${this.quoteIdentifier(column)} = ?`) .join(', '); return this.case({ mysql: `ON DUPLICATE KEY UPDATE ${updateList}`, otherwise: `ON CONFLICT(${conflictColumns .map((column) => this.quoteIdentifier(column)) .join(', ')}) DO UPDATE SET ${updateList}`, }); } jsonTextExtract(jsonExpression: string, path: SqlJsonPath): string { const sqlitePath = `$${path.map((part) => `.${part}`).join('')}`; return this.case({ sqlite: `json_extract(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)})`, mysql: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`, postgres: `${jsonExpression} #>> ARRAY[${path .map((part) => this.sqlStringLiteral(part)) .join(', ')}]`, otherwise: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`, }); } /** * Whole `periodSeconds` buckets since the epoch, from a unix-seconds * column. MySQL's `/` yields a decimal, which would split a bucket. */ periodBucket(secondsExpression: string, periodSeconds: number): string { return this.case({ mysql: `(${secondsExpression} DIV ${periodSeconds})`, otherwise: `(${secondsExpression} / ${periodSeconds})`, }); } /** * Counts each (`userExpression`, bucket) pair once. `COUNT(DISTINCT a, b)` * is MySQL-only, so the pair folds into one number — injective while the * bucket stays under the multiplier, which hourly reaches in the 2980s. */ countDistinctUserPeriods( userExpression: string, secondsExpression: string, periodSeconds: number, ): string { return `COUNT(DISTINCT ${userExpression} * 10000000 + ${this.periodBucket(secondsExpression, periodSeconds)})`; } nullCoalesce(...expressions: readonly string[]): string { if (expressions.length === 0) { throw new Error('nullCoalesce requires at least one expression'); } return `COALESCE(${expressions.join(', ')})`; } returningIdClause(): string { return this.case({ postgres: ' RETURNING id', otherwise: '', }); } /** * A timestamp column value from one bound parameter in unix seconds, stored * the way `CURRENT_TIMESTAMP` would store that instant. */ timestampFromUnixSql(): string { return this.case({ sqlite: "datetime(?, 'unixepoch')", postgres: 'to_timestamp(?)', otherwise: 'FROM_UNIXTIME(?)', }); } /** * A timestamp column as unix seconds, computed by the database: the mysql * driver reads a stored UTC timestamp as local time, so parsing it in JS * can be off by the server's UTC offset. */ unixSecondsSql(column: string): string { return this.case({ postgres: `EXTRACT(EPOCH FROM ${column})::bigint`, mysql: `UNIX_TIMESTAMP(${column})`, otherwise: `CAST(strftime('%s', ${column}) AS INTEGER)`, }); } /** * What a read of a timestamp column holding `seconds` returns: sqlite gives * back the UTC text it stored, mysql2 and pg a `Date` read in node's time * zone (so this matches when node and the database session share one). */ timestampValue(seconds: number): Date | string { const date = new Date(seconds * 1000); return this.case({ sqlite: date.toISOString().slice(0, 19).replace('T', ' '), otherwise: date, }); } protected sqlStringLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'`; } } /** Await later without an unhandled rejection in the meantime. */ const settle = async ( promise: Promise, ): Promise<{ value: T } | { error: unknown }> => { try { return { value: await promise }; } catch (error) { return { error }; } };