UNPKG

@paroicms/internal-server-lib

Version:

Common utilitaries for the paroicms server.

239 lines 8.21 kB
import { type } from "arktype"; import knex from "knex"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { pathExists } from "./fs-utils.js"; export const METADATA_TABLE_NAME = "PaMetadata"; export const DB_SCHEMA_VERSION_KEY = "dbSchemaVersion"; export async function createOrOpenSqliteConnection(options) { const { sqliteFile, ddlFile, dbSchemaName, canCreate, migrateDb, logger, knexLogger, ensureDependsOn, } = options; const isNewDatabase = !(await pathExists(sqliteFile)); if (isNewDatabase && !canCreate) { throw new Error(`missing '${dbSchemaName}' database`); } const cn = knex({ client: "sqlite3", connection: { filename: sqliteFile, }, useNullAsDefault: true, debug: true, // process.env.NODE_ENV === "development" asyncStackTraces: true, // process.env.NODE_ENV === "development" log: knexLogger, pool: { afterCreate: (sqliteCn, done) => { // Enable foreign keys in SQLite sqliteCn.run("PRAGMA foreign_keys = 1", done); }, min: 0, idleTimeoutMillis: 30_000, }, }); let wasReset = false; let shouldInitializeMetadata = false; if (isNewDatabase) { await executeDdl(cn, ddlFile); logger.info(`created '${dbSchemaName}' database`); shouldInitializeMetadata = true; } let migrationReport = await migrateDb(cn); if (migrationReport.migrated && isNewDatabase) { throw new Error(`unexpected migration of newly created '${dbSchemaName}' database from version ${migrationReport.fromVersion} to ${migrationReport.schemaVersion}`); } if (ensureDependsOn && !isNewDatabase) { const storedDependsOn = await getMetadataValue(cn, { dbSchemaName, key: "dependsOn", }); if (storedDependsOn !== ensureDependsOn.dependsOn) { if (ensureDependsOn.onMismatch === "error") { throw new Error(`[${dbSchemaName}] dependsOn mismatch: expected '${ensureDependsOn.dependsOn}', found '${storedDependsOn}'`); } logger.info(`[${dbSchemaName}] Reset database due to '${ensureDependsOn.dependsOn}', previous value was '${storedDependsOn}'`); await executeDdl(cn, ddlFile, { dropAll: true }); wasReset = true; shouldInitializeMetadata = true; migrationReport = await migrateDb(cn); } } let databaseId; if (shouldInitializeMetadata) { const inserted = await insertNewDatabaseMetadataValues(cn, { dbSchemaName, dependsOn: ensureDependsOn?.dependsOn, }); databaseId = inserted.databaseId; } else { databaseId = await getMetadataValue(cn, { dbSchemaName, key: "databaseId", }); if (databaseId === undefined) { throw new Error(`[${dbSchemaName}] missing 'databaseId' in '${METADATA_TABLE_NAME}'`); } } return { cn, newDatabase: isNewDatabase ? "created" : wasReset ? "reset" : false, migrationReport, databaseId, }; } export async function insertNewDatabaseMetadataValues(cn, options) { const { dbSchemaName, dependsOn } = options; const databaseId = generateDatabaseId(); await setMetadataValue(cn, { dbSchemaName, key: "databaseId", value: databaseId, }); if (dependsOn) { await setMetadataValue(cn, { dbSchemaName, key: "dependsOn", value: dependsOn, }); } return { databaseId }; } export function generateDatabaseId() { return randomUUID(); } const DropAllUserTablesRowAT = type({ name: "string", type: "string", "+": "reject", }); export async function executeDdl(cn, ddlFile, { dropAll } = {}) { if (dropAll) { await dropAllUserTables(cn); } const ddl = await readFile(ddlFile, { encoding: "utf-8", }); const queries = ddl .split(";") .map((query) => query.trim()) .filter((query) => query !== ""); for (const query of queries) { if (!onlySqlComment(query)) { await cn.raw(query); } } } export async function dropAllUserTables(cn) { await cn.raw("pragma foreign_keys = 0"); try { const rows = await cn("sqlite_master") .select("name", "type") .whereIn("type", ["table", "view"]) .where("name", "not like", "sqlite_%"); for (const row of rows) { const { name, type } = DropAllUserTablesRowAT.assert(row); const quotedName = name.replace(/"/g, '"'); if (type === "view") { await cn.raw(`drop view if exists "${quotedName}"`); } else { await cn.raw(`drop table if exists "${quotedName}"`); } } const sqliteSequenceExists = await cn("sqlite_master") .select("name") .where({ type: "table", name: "sqlite_sequence" }) .first(); if (sqliteSequenceExists) { await cn.raw("delete from sqlite_sequence"); } } finally { await cn.raw("pragma foreign_keys = 1"); } } function onlySqlComment(sql) { const lines = sql.split(/\r\n?|\n/); for (let line of lines) { line = line.trim(); if (line && !line.startsWith("--")) return false; } return true; } const GetMetadataValueAT = type({ val: "string", "+": "reject" }); export async function getMetadataValue(cn, { dbSchemaName, key }) { const row = await cn(METADATA_TABLE_NAME) .select("val") .where({ dbSchema: dbSchemaName, k: key }) .first(); if (row) { return GetMetadataValueAT.assert(row).val; } } export async function setMetadataValue(cn, { dbSchemaName, key, value, errorIfMissing, }) { const affectedRows = await cn(METADATA_TABLE_NAME) .where({ dbSchema: dbSchemaName, k: key }) .update({ val: value }); if (affectedRows === 0) { if (errorIfMissing) { throw new Error(`[${dbSchemaName}] missing '${key}' in '${METADATA_TABLE_NAME}'`); } await cn(METADATA_TABLE_NAME).insert({ dbSchema: dbSchemaName, k: key, val: value, }); } } export async function getMetadataDbSchemaVersion(cn, { dbSchemaName }) { let value; try { value = await getMetadataValue(cn, { dbSchemaName, key: DB_SCHEMA_VERSION_KEY }); if (value === undefined) { throw new Error(`[${dbSchemaName}] missing '${DB_SCHEMA_VERSION_KEY}' in '${METADATA_TABLE_NAME}'`); } } catch (error) { try { value = await migrateMetadataTable(cn, dbSchemaName); } catch { throw error; } } return Number(value); } export async function setMetadataDbSchemaVersion(cn, { dbSchemaName, value }) { await setMetadataValue(cn, { dbSchemaName, key: DB_SCHEMA_VERSION_KEY, value: String(value), errorIfMissing: true, }); } const MigrateMetadataTableAT = type({ dbVersion: "number", "+": "reject" }); // Note: deprecated table async function migrateMetadataTable(cn, dbSchemaName) { const row = await cn("PaDbMetadata") .select("dbVersion") .where({ dbSchema: dbSchemaName }) .first(); if (!row) throw new Error(); // will be catched in the caller const version = MigrateMetadataTableAT.assert(row).dbVersion; await cn.raw(`create table PaMetadata ( dbSchema varchar(100) not null, k varchar(100) not null, val varchar(250) not null, primary key (dbSchema, k) )`); await cn(METADATA_TABLE_NAME).insert({ dbSchema: dbSchemaName, k: "dbSchemaVersion", val: String(version), }); await cn.schema.dropTable("PaDbMetadata"); console.warn(`[${dbSchemaName}] … migrated 'PaDbMetadata' table to 'PaMetadata'`); return String(version); } //# sourceMappingURL=sqlite-db-init.js.map