diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e4189..78aa5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.6.20 - 2026-08-09 + +### Changed + +- Startup now logs the previously detected schema version, the current app version, and whether migrations are required. + ## 2.6.19 - 2026-08-09 ### Changed diff --git a/build/package.player.json b/build/package.player.json index e23722b..513fbb0 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.6.19", + "version": "2.6.20", "private": false, "description": "Pulse Signage player application bundle", "main": "src/common.js", diff --git a/build/package.web.json b/build/package.web.json index 0a300f1..421bf29 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.6.19", + "version": "2.6.20", "private": false, "description": "Pulse Signage web and bridge application bundle", "main": "src/common.js", diff --git a/package.json b/package.json index 5ebf5a5..348ab2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.19", + "version": "2.6.20", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/db/index.js b/src/db/index.js index e923404..d480070 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -1,3 +1,6 @@ +const { version: appVersion } = require('#root/package.json'); +const { compareVersions, detectSchemaVersion, recordSchemaVersion, runMigrations } = require('./migrations'); + // Snapshot only: keep this file aligned with the current schema state. async function ensureSchema(pool, options) { const schemaLockName = 'pulse_signage_schema_lock'; @@ -12,6 +15,11 @@ async function ensureSchema(pool, options) { } try { + const currentVersion = await detectSchemaVersion(pool); + const updateRequired = compareVersions(currentVersion, appVersion) < 0; + + console.info('[schema] previous=' + currentVersion + ' current=' + appVersion + ' update=' + (updateRequired ? 'yes' : 'no')); + await pool.query(` CREATE TABLE IF NOT EXISTS c_canvas_sizes ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -384,8 +392,8 @@ async function ensureSchema(pool, options) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - const { runMigrations } = require('./migrations'); - await runMigrations(pool, options); + await runMigrations(pool, Object.assign({}, options, { currentVersion: currentVersion })); + await recordSchemaVersion(pool, appVersion); } finally { await pool.query('SELECT RELEASE_LOCK(?)', [schemaLockName]).catch(function () { }); diff --git a/src/db/migrations.js b/src/db/migrations.js index 147b87d..1581656 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,5 +1,7 @@ const { version: appVersion } = require('#root/package.json'); const TIMETABLE_TIME_ZONE = 'Europe/London'; +const APP_STATE_TABLE = 'o_app_state'; +const APP_STATE_SCHEMA_VERSION_KEY = 'schema_version'; const VERSIONED_MIGRATIONS = [ { @@ -301,23 +303,42 @@ const VERSIONED_MIGRATIONS = [ version: '2.6.16', label: 'v2.6.16 timetable timezone schema', run: async function (pool) { - await ensureColumn(pool, 'i_schedule_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); + const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); + const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); + const tableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null; + + if (!tableName) { + return; + } + + await ensureColumn(pool, tableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); } }, { version: '2.6.17', label: 'v2.6.17 timetable europe/london conversion', run: async function (pool) { - await ensureColumn(pool, 'i_schedule_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); + const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); + const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries'); + const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); + const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries'); + const groupTableName = timetableGroupsExists ? 'i_timetable_groups' : scheduleGroupsExists ? 'i_schedule_groups' : null; + const entryTableName = timetableEntriesExists ? 'i_timetable_entries' : scheduleEntriesExists ? 'i_schedule_entries' : null; - await pool.query('UPDATE i_schedule_groups SET timezone = ?', [TIMETABLE_TIME_ZONE]); + if (!groupTableName || !entryTableName) { + return; + } - const [rows] = await pool.query('SELECT id, start_datetime, end_datetime FROM i_schedule_entries ORDER BY id ASC'); + await ensureColumn(pool, groupTableName, 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); + + await pool.query('UPDATE ' + groupTableName + ' SET timezone = ?', [TIMETABLE_TIME_ZONE]); + + const [rows] = await pool.query('SELECT id, start_datetime, end_datetime FROM ' + entryTableName + ' ORDER BY id ASC'); for (const row of rows) { const startDate = convertMigrationDateTimeFromTimeZone(row.start_datetime, TIMETABLE_TIME_ZONE); const endDate = row.end_datetime ? convertMigrationDateTimeFromTimeZone(row.end_datetime, TIMETABLE_TIME_ZONE) : null; await pool.query( - 'UPDATE i_schedule_entries SET start_datetime = ?, end_datetime = ? WHERE id = ?', + 'UPDATE ' + entryTableName + ' SET start_datetime = ?, end_datetime = ? WHERE id = ?', [formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id] ); } @@ -333,12 +354,15 @@ const VERSIONED_MIGRATIONS = [ if (scheduleGroupsExists) { if (!timetableGroupsExists) { await pool.query('RENAME TABLE i_schedule_groups TO i_timetable_groups, i_schedule_entries TO i_timetable_entries'); + await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); return; } + await ensureColumn(pool, 'i_timetable_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); + await pool.query(` INSERT IGNORE INTO i_timetable_groups (id, name, short_description, timezone, created_at, created_by, modified_at, modified_by) - SELECT id, name, short_description, timezone, created_at, created_by, modified_at, modified_by + SELECT id, name, short_description, 'Europe/London' AS timezone, created_at, created_by, modified_at, modified_by FROM i_schedule_groups ORDER BY id ASC `); @@ -389,6 +413,47 @@ async function tableExists(pool, tableName) { return Number(rows && rows[0] && rows[0].table_count) > 0; } +async function detectSchemaVersion(pool) { + if (await tableExists(pool, APP_STATE_TABLE)) { + const [rows] = await pool.query( + 'SELECT state_value FROM ' + APP_STATE_TABLE + ' WHERE state_key = ? LIMIT 1', + [APP_STATE_SCHEMA_VERSION_KEY] + ); + + const storedVersion = String(rows && rows[0] && rows[0].state_value || '').trim(); + if (storedVersion) { + return storedVersion; + } + } + + const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); + const timetableEntriesExists = await tableExists(pool, 'i_timetable_entries'); + const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); + const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries'); + + if ((timetableGroupsExists || timetableEntriesExists) && !scheduleGroupsExists && !scheduleEntriesExists) { + return '2.6.18'; + } + + return '0.0.0'; +} + +async function recordSchemaVersion(pool, version) { + await pool.query( + `CREATE TABLE IF NOT EXISTS ${APP_STATE_TABLE} ( + state_key VARCHAR(191) NOT NULL PRIMARY KEY, + state_value MEDIUMTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` + ); + + await pool.query( + 'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE state_value = VALUES(state_value)', + [APP_STATE_SCHEMA_VERSION_KEY, String(version || appVersion || '0.0.0').trim()] + ); +} + async function columnIsAutoIncrement(pool, tableName, columnName) { const [rows] = await pool.query( `SELECT COUNT(*) AS auto_increment_count @@ -772,5 +837,8 @@ async function runMigrations(pool, options) { module.exports = { appVersion: appVersion, - runMigrations: runMigrations + runMigrations: runMigrations, + detectSchemaVersion: detectSchemaVersion, + recordSchemaVersion: recordSchemaVersion, + compareVersions: compareVersions }; diff --git a/test/timetable-migrations.test.js b/test/timetable-migrations.test.js new file mode 100644 index 0000000..0d222ed --- /dev/null +++ b/test/timetable-migrations.test.js @@ -0,0 +1,145 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +require('../src/common'); + +const { runMigrations } = require('../src/db/migrations'); + +function createPool(responses) { + const queries = []; + return { + queries, + async query(sql, params) { + queries.push([sql, params]); + const text = String(sql); + for (const response of responses) { + if (response.match(text, params)) { + return response.result; + } + } + return [[]]; + } + }; +} + +test('timetable rename migration does not depend on legacy timezone column', async () => { + const pool = createPool([ + { + match(sql) { + return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?'); + }, + result: [[{ column_count: 0 }]] + }, + { + match(sql) { + return sql.includes('FROM information_schema.TABLES') && sql.includes('TABLE_NAME = ?'); + }, + result: [[{ table_count: 1 }]] + }, + { + match(sql) { + return sql.includes('SELECT id, name, short_description,') && sql.includes("'Europe/London' AS timezone") && sql.includes('FROM i_schedule_groups'); + }, + result: [{}] + }, + { + match(sql) { + return sql.includes('SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by FROM i_schedule_entries'); + }, + result: [{}] + }, + { + match(sql) { + return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups'); + }, + result: [[{ max_id: 1 }]] + }, + { + match(sql) { + return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries'); + }, + result: [[{ max_id: 2 }]] + } + ]); + + await runMigrations(pool, { currentVersion: '2.6.17' }); + + assert.ok(pool.queries.some(function ([sql]) { + return String(sql).includes("SELECT id, name, short_description, 'Europe/London' AS timezone") && String(sql).includes('FROM i_schedule_groups'); + })); + + assert.ok(!pool.queries.some(function ([sql]) { + return String(sql).includes('SELECT id, name, short_description, timezone, created_at, created_by, modified_at, modified_by') && String(sql).includes('FROM i_schedule_groups'); + })); +}); + +test('timetable timezone migrations use timetable tables when legacy tables are gone', async () => { + const pool = createPool([ + { + match(sql) { + return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?'); + }, + result: [[{ column_count: 0 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_timetable_groups'; + }, + result: [[{ table_count: 1 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_timetable_entries'; + }, + result: [[{ table_count: 1 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_schedule_groups'; + }, + result: [[{ table_count: 0 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_schedule_entries'; + }, + result: [[{ table_count: 0 }]] + }, + { + match(sql) { + return sql.includes('SELECT id, start_datetime, end_datetime FROM i_timetable_entries ORDER BY id ASC'); + }, + result: [[{ id: 1, start_datetime: '2026-08-09 09:00:00', end_datetime: '2026-08-09 10:00:00' }]] + }, + { + match(sql) { + return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups'); + }, + result: [[{ max_id: 1 }]] + }, + { + match(sql) { + return sql.includes('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries'); + }, + result: [[{ max_id: 1 }]] + } + ]); + + await runMigrations(pool, { currentVersion: '2.6.15' }); + + assert.ok(pool.queries.some(function ([sql]) { + return String(sql).includes('ALTER TABLE i_timetable_groups ADD COLUMN timezone'); + })); + + assert.ok(pool.queries.some(function ([sql]) { + return String(sql).includes('UPDATE i_timetable_groups SET timezone = ?'); + })); + + assert.ok(pool.queries.some(function ([sql]) { + return String(sql).includes('UPDATE i_timetable_entries SET start_datetime = ?, end_datetime = ? WHERE id = ?'); + })); + + assert.ok(!pool.queries.some(function ([sql]) { + return String(sql).includes('i_schedule_groups') || String(sql).includes('i_schedule_entries'); + })); +}); \ No newline at end of file