const { query } = require('../../utils/db') // SQL for installed_modules — the module system's record of what is installed and // what happened to it on the last boot (db/schema.sql, docs/website/MODULE_SYSTEM.md // §2.4). Rows are keyed by module id. All state rules live in modules.model.js; // this file only moves rows. const COLS = `id, name, version, state, failure_stage, failure_reason, source, sha256, installed_at, started_at, updated_at` const listAll = () => query(`SELECT ${COLS} FROM installed_modules ORDER BY id`) const getOne = (id) => query(`SELECT ${COLS} FROM installed_modules WHERE id = ?`, [id]) // Write (or refresh) the row for an installed module. A re-install or an upgrade // updates the metadata and deliberately leaves `state` alone: upgrading an enabled // module must not silently disable it, and re-installing a disabled one must not // silently switch it back on. A brand-new row lands in `installed`, the transient // state the next restart resolves. // Provenance is COALESCEd, and that is the whole difference between this // working and not. // // `lifecycle.boot()` re-records every module it scanned with no source and no // sha256 — a directory placed on the volume by hand genuinely has neither, and // the boot has no way to know where one came from. With a plain // `source = VALUES(source)` that refresh overwrote both columns with NULL on // EVERY boot, so an admin-panel install's provenance survived exactly until the // restart that install asked for. Nothing could catch it before Phase 4: until // then no caller ever passed a non-null value, and lifecycle.js's comment // asserting that "recordInstalled leaves what it is not given" was a description // of an intention rather than of this statement. // // COALESCE makes that comment true: a value overwrites, a NULL leaves what is // there. The cost is that re-placing a DIFFERENT bundle by hand over a row that // was once installed from a URL keeps the old provenance — which is wrong but // stale, and strictly better than the alternative, which was wrong and blank. const upsert = ({ id, name, version, source, sha256 }) => query( `INSERT INTO installed_modules (id, name, version, source, sha256, state) VALUES (?, ?, ?, ?, ?, 'installed') ON DUPLICATE KEY UPDATE name = VALUES(name), version = VALUES(version), source = COALESCE(VALUES(source), source), sha256 = COALESCE(VALUES(sha256), sha256)`, [id, name, version, source ?? null, sha256 ?? null], ) // Move one row to a new state. `failureStage`/`failureReason` are written on every // call — a non-failing transition passes nulls, which is what clears a stale reason // off a module that has since come up. `stampStarted` sets started_at to now. const setState = ({ id, state, failureStage = null, failureReason = null, stampStarted = false }) => query( `UPDATE installed_modules SET state = ?, failure_stage = ?, failure_reason = ? ${stampStarted ? ', started_at = CURRENT_TIMESTAMP' : ''} WHERE id = ?`, [state, failureStage, failureReason, id], ) // Boot reset: every row the operator has not disabled goes back to `enabled` with // no failure recorded, so the load that follows writes this boot's outcome rather // than leaving the last one on display. `disabled` is untouched — it is a decision, // not an outcome. const resetForBoot = () => query( `UPDATE installed_modules SET state = 'enabled', failure_stage = NULL, failure_reason = NULL WHERE state <> 'disabled'`, ) // Drop the row entirely. Only the explicit purge does this (§2.5); a plain // uninstall disables the module and keeps its row and its data. const remove = (id) => query('DELETE FROM installed_modules WHERE id = ?', [id]) module.exports = { listAll, getOne, upsert, setState, resetForBoot, remove }