Files
pulse-signage/src/db/migrations.js
T
2026-08-09 13:06:02 +01:00

852 lines
32 KiB
JavaScript

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 = [
{
version: '2.1.0',
label: 'v2.1.0 player registry schema',
run: async function (pool) {
// Keep the player registry as the source of truth for device metadata.
if (!(await columnExists(pool, 'd_players', 'public_base_url'))) {
await pool.query(`
CREATE TABLE IF NOT EXISTS d_players (
device_id VARCHAR(128) PRIMARY KEY,
public_base_url VARCHAR(512) NULL,
internal_base_url VARCHAR(512) NULL,
last_seen_at TIMESTAMP 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
`);
}
// Store the player pointer on screens so we can resolve the player without needing a player-side screen_id.
// This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'.
if (!(await columnExists(pool, 'd_screens', 'player_id'))) {
await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
} else {
await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'");
const [playerColumnNullableRows] = await pool.query(
`SELECT COUNT(*) AS nullable_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND COLUMN_NAME = 'player_id'
AND IS_NULLABLE = 'YES'`
);
if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) {
await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id");
}
}
const [screenPlayerUniqueRows] = await pool.query(
`SELECT COUNT(*) AS index_count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND INDEX_NAME = 'uq_screens_player_id'`
);
if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) {
await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id');
}
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT');
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
}
}
},
{
version: '2.1.1',
label: 'v2.1.1 API source schema',
run: async function (pool) {
await ensureColumn(pool, 'i_api_sources', 'auth_method', "VARCHAR(32) NOT NULL DEFAULT 'none'", 'api_url');
await ensureColumn(pool, 'i_api_sources', 'auth_username', 'VARCHAR(255) NULL', 'auth_method');
await ensureColumn(pool, 'i_api_sources', 'auth_password', 'MEDIUMTEXT NULL', 'auth_username');
await ensureColumn(pool, 'i_api_sources', 'auth_bearer_token', 'MEDIUMTEXT NULL', 'auth_password');
await ensureColumn(pool, 'i_api_sources', 'auth_header_name', 'VARCHAR(255) NULL', 'auth_bearer_token');
await ensureColumn(pool, 'i_api_sources', 'auth_header_value', 'MEDIUMTEXT NULL', 'auth_header_name');
await ensureColumn(pool, 'i_api_sources', 'items_path', 'VARCHAR(255) NULL', 'auth_header_value');
}
},
{
version: '2.2.0',
label: 'v2.2.0 announcement schema',
run: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS d_announcements (
id INT AUTO_INCREMENT PRIMARY KEY,
message TEXT NOT NULL,
short_label VARCHAR(255) NOT NULL DEFAULT '',
announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third',
color_key VARCHAR(32) NOT NULL DEFAULT 'primary',
icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill',
duration_seconds INT NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
INDEX idx_announcements_expires_at (expires_at),
INDEX idx_announcements_modified_at (modified_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
},
{
version: '2.4.0',
label: 'v2.4.0 playlist schedule rules schema',
run: async function (pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
playlist_slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
start_datetime DATETIME NULL,
end_datetime DATETIME NULL,
start_time TIME NULL,
end_time TIME NULL,
schedule_days_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const legacyColumnNames = [
'schedule_mode',
'schedule_start_datetime',
'schedule_end_datetime',
'schedule_start_time',
'schedule_end_time',
'schedule_days_json',
'schedule_rules_json'
];
const existingLegacyColumns = [];
for (const columnName of legacyColumnNames) {
if (await columnExists(pool, 'c_playlist_slides', columnName)) {
existingLegacyColumns.push(columnName);
}
}
const selectColumns = ['id'].concat(existingLegacyColumns).join(', ');
const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides');
for (const row of legacyRows) {
const rules = normalizeLegacyScheduleRules(row);
if (rules.length) {
for (let index = 0; index < rules.length; index += 1) {
const rule = rules[index];
await pool.query(
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)',
[row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null]
);
}
}
}
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time');
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json');
await pool.query(`
CREATE TABLE IF NOT EXISTS d_announcement_screens (
announcement_id INT NOT NULL,
screen_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
PRIMARY KEY (announcement_id, screen_id),
INDEX idx_announcement_screens_screen_id (screen_id),
CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE,
CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
},
{
version: '2.4.2',
label: 'v2.4.2 playlist canvas id schema',
run: async function (pool) {
await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp');
await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL');
await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration');
await pool.query(`
UPDATE c_playlists p
JOIN (
SELECT ps.playlist_id,
COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count,
MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_id
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE cs.width IS NOT NULL
AND cs.height IS NOT NULL
GROUP BY ps.playlist_id
) x ON x.playlist_id = p.id
JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature
SET p.canvas_id = cs.id
WHERE (p.canvas_id IS NULL)
AND x.signature_count = 1
`);
}
},
{
version: '2.5.1',
label: 'v2.5.1 template animation json schema',
run: async function (pool) {
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
}
},
{
version: '2.6.2',
label: 'v2.6.2 player identity schema',
run: async function (pool) {
if (!(await columnExists(pool, 'd_players', 'device_id'))) {
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
if (!(await columnExists(pool, 'd_players', 'identifier'))) {
await ensureColumn(pool, 'd_players', 'identifier', 'VARCHAR(128) NOT NULL UNIQUE', 'id');
}
if (!(await columnExists(pool, 'd_players', 'id'))) {
await ensureColumn(pool, 'd_players', 'id', 'INT NOT NULL AUTO_INCREMENT', null);
await pool.query('ALTER TABLE d_players ADD PRIMARY KEY (id)');
} else if (!(await columnIsAutoIncrement(pool, 'd_players', 'id'))) {
await pool.query('ALTER TABLE d_players MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT');
}
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
return;
}
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
await pool.query('DROP TABLE IF EXISTS d_players_rebuild');
await pool.query(`
CREATE TABLE d_players_rebuild (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(128) NOT NULL UNIQUE,
public_base_url VARCHAR(512) NULL,
internal_base_url VARCHAR(512) NULL,
last_seen_at TIMESTAMP 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 d_players_rebuild (identifier, public_base_url, internal_base_url, last_seen_at, created_at, modified_at)
SELECT DISTINCT
device_id AS identifier,
public_base_url,
internal_base_url,
last_seen_at,
created_at,
modified_at
FROM d_players
ORDER BY COALESCE(created_at, modified_at, device_id), device_id
`);
await pool.query('DROP TEMPORARY TABLE IF EXISTS d_player_id_map');
await pool.query(`
CREATE TEMPORARY TABLE d_player_id_map AS
SELECT old_players.device_id AS old_device_id, rebuilt_players.id AS new_player_id
FROM d_players old_players
JOIN d_players_rebuild rebuilt_players ON rebuilt_players.identifier = old_players.device_id
`);
await pool.query(
`UPDATE d_screens s
JOIN d_player_id_map m ON m.old_device_id = CAST(s.player_id AS CHAR)
SET s.player_id = m.new_player_id
WHERE s.player_id IS NOT NULL`
);
await pool.query('DROP TABLE d_players');
await pool.query('RENAME TABLE d_players_rebuild TO d_players');
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
return;
}
},
{
version: '2.6.3',
label: 'v2.6.3 screen-player fk removal',
run: async function (pool) {
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
if (await columnExists(pool, 'd_screens', 'player_id')) {
await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id');
}
}
},
{
version: '2.6.4',
label: 'v2.6.4 drop screen player id',
run: async function (pool) {
await dropForeignKeyIfExists(pool, 'd_screens', 'player_id');
await dropColumnIfExists(pool, 'd_screens', 'player_id');
}
},
{
version: '2.6.16',
label: 'v2.6.16 timetable timezone schema',
run: async function (pool) {
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) {
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;
if (!groupTableName || !entryTableName) {
return;
}
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 ' + entryTableName + ' SET start_datetime = ?, end_datetime = ? WHERE id = ?',
[formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id]
);
}
}
},
{
version: '2.6.18',
label: 'v2.6.18 timetable table rename',
run: async function (pool) {
const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups');
const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups');
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, 'Europe/London' AS timezone, created_at, created_by, modified_at, modified_by
FROM i_schedule_groups
ORDER BY id ASC
`);
await pool.query(`
INSERT IGNORE INTO i_timetable_entries (id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by)
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by
FROM i_schedule_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`);
const [groupRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups');
const [entryRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries');
const nextGroupId = Number(groupRows && groupRows[0] && groupRows[0].max_id) + 1;
const nextEntryId = Number(entryRows && entryRows[0] && entryRows[0].max_id) + 1;
await pool.query('ALTER TABLE i_timetable_groups AUTO_INCREMENT = ' + nextGroupId);
await pool.query('ALTER TABLE i_timetable_entries AUTO_INCREMENT = ' + nextEntryId);
await pool.query('DROP TABLE i_schedule_entries');
await pool.query('DROP TABLE i_schedule_groups');
}
}
}
];
async function columnExists(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?`,
[tableName, columnName]
);
return Number(rows && rows[0] && rows[0].column_count) > 0;
}
async function tableExists(pool, tableName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS table_count
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?`,
[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
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND EXTRA LIKE '%auto_increment%'`,
[tableName, columnName]
);
return Number(rows && rows[0] && rows[0].auto_increment_count) > 0;
}
async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) {
if (await columnExists(pool, tableName, columnName)) {
return;
}
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
try {
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
} catch (error) {
if (!error || (error.code !== 'ER_DUP_FIELDNAME' && error.errno !== 1060)) {
throw error;
}
}
}
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
const [existingFkRows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL`,
[tableName, columnName]
);
if (Number(existingFkRows && existingFkRows[0] && existingFkRows[0].fk_count) > 0) {
return;
}
const [rows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND CONSTRAINT_NAME = ?`,
[constraintName]
);
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
return;
}
const fallbackNames = [
constraintName,
tableName + '_' + columnName + '_fk',
tableName + '_' + columnName + '_fk_2',
tableName + '_' + columnName + '_fk_3'
];
for (const candidateName of fallbackNames) {
try {
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
return;
} catch (error) {
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826 && error.errno !== 121)) {
throw error;
}
}
}
}
async function dropForeignKeyIfExists(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT CONSTRAINT_NAME AS constraint_name
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL
LIMIT 1`,
[tableName, columnName]
);
const constraintName = String(rows && rows[0] && rows[0].constraint_name || '').trim();
if (!constraintName) {
return;
}
try {
await pool.query('ALTER TABLE ' + tableName + ' DROP FOREIGN KEY ' + constraintName);
} catch (error) {
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
throw error;
}
}
}
async function dropColumnIfExists(pool, tableName, columnName) {
if (await columnExists(pool, tableName, columnName)) {
try {
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
} catch (error) {
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
throw error;
}
}
}
}
function normalizeLegacyScheduleRules(row) {
const rules = [];
if (!row) {
return rules;
}
if (row.schedule_rules_json) {
try {
const parsed = JSON.parse(row.schedule_rules_json);
if (Array.isArray(parsed)) {
parsed.forEach(function (rule) {
const normalized = normalizeScheduleRule(rule);
if (normalized) {
rules.push(normalized);
}
});
return rules;
}
} catch (_error) {
// fall through to legacy columns
}
}
const mode = String(row.schedule_mode || 'always').trim().toLowerCase();
if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) {
rules.push({
start_datetime: formatMigrationDateTime(row.schedule_start_datetime),
end_datetime: formatMigrationDateTime(row.schedule_end_datetime)
});
return rules;
}
if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) {
const days = normalizeMigrationDays(row.schedule_days_json);
const rule = {
start_time: formatMigrationTime(row.schedule_start_time),
end_time: formatMigrationTime(row.schedule_end_time)
};
if (days.length) {
rule.schedule_days_json = days;
}
rules.push(rule);
}
return rules;
}
function normalizeScheduleRule(rule) {
if (!rule || typeof rule !== 'object') {
return null;
}
const normalized = {};
if (rule.start_datetime) {
normalized.start_datetime = String(rule.start_datetime);
}
if (rule.end_datetime) {
normalized.end_datetime = String(rule.end_datetime);
}
if (rule.start_time) {
normalized.start_time = String(rule.start_time).slice(0, 5);
}
if (rule.end_time) {
normalized.end_time = String(rule.end_time).slice(0, 5);
}
if (Array.isArray(rule.schedule_days_json)) {
normalized.schedule_days_json = rule.schedule_days_json;
} else if (Array.isArray(rule.days)) {
normalized.schedule_days_json = rule.days;
}
return Object.keys(normalized).length ? normalized : null;
}
function formatMigrationDateTime(value) {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
}
function parseMigrationDateTimeParts(value) {
if (!value) {
return null;
}
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
return null;
}
return {
year: value.getUTCFullYear(),
month: value.getUTCMonth() + 1,
day: value.getUTCDate(),
hour: value.getUTCHours(),
minute: value.getUTCMinutes(),
second: value.getUTCSeconds()
};
}
const raw = String(value || '').trim();
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
if (!match) {
return null;
}
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] || 0),
minute: Number(match[5] || 0),
second: Number(match[6] || 0)
};
}
function getMigrationTimeZoneOffsetMillis(date, timeZone) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return 0;
}
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date).reduce(function (acc, part) {
if (part && part.type && part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, Object.create(null));
const localAsUtc = Date.UTC(
Number(parts.year) || 0,
(Number(parts.month) || 1) - 1,
Number(parts.day) || 1,
Number(parts.hour) || 0,
Number(parts.minute) || 0,
Number(parts.second) || 0,
0
);
return localAsUtc - date.getTime();
}
function convertMigrationDateTimeFromTimeZone(value, timeZone) {
const parts = parseMigrationDateTimeParts(value);
if (!parts) {
return null;
}
const utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
let adjusted = new Date(utcMillis - getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone));
const adjustedOffset = getMigrationTimeZoneOffsetMillis(adjusted, timeZone);
if (adjustedOffset !== getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone)) {
adjusted = new Date(utcMillis - adjustedOffset);
}
return adjusted;
}
function formatMigrationDateTimeUtc(value) {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
const hours = String(value.getUTCHours()).padStart(2, '0');
const minutes = String(value.getUTCMinutes()).padStart(2, '0');
const seconds = String(value.getUTCSeconds()).padStart(2, '0');
return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds;
}
function formatMigrationTime(value) {
if (!value) {
return null;
}
return String(value).slice(0, 5);
}
function normalizeMigrationDays(value) {
let parsed = [];
if (Array.isArray(value)) {
parsed = value;
} else if (value) {
try {
const raw = typeof value === 'string' ? JSON.parse(value) : value;
parsed = Array.isArray(raw) ? raw : [];
} catch (_error) {
parsed = [];
}
}
return Array.from(new Set(parsed.map(function (day) {
return Number(day);
}).filter(function (day) {
return Number.isInteger(day) && day >= 0 && day <= 6;
}))).sort(function (left, right) {
return left - right;
});
}
function compareVersions(leftVersion, rightVersion) {
const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) {
return Number(value) || 0;
});
const rightParts = String(rightVersion || '0.0.0').split('.').map(function (value) {
return Number(value) || 0;
});
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const leftPart = leftParts[index] || 0;
const rightPart = rightParts[index] || 0;
if (leftPart > rightPart) {
return 1;
}
if (leftPart < rightPart) {
return -1;
}
}
return 0;
}
async function getPendingMigrations(pool, options) {
const targetVersion = String(appVersion || '0.0.0').trim();
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id');
const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id');
const legacyTimetableGroupsPresent = await tableExists(pool, 'i_schedule_groups');
const legacyTimetableEntriesPresent = await tableExists(pool, 'i_schedule_entries');
let effectiveCurrentVersion = currentVersion;
if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) {
effectiveCurrentVersion = '2.1.0';
}
if (!screenPlayerColumnPresent && compareVersions(effectiveCurrentVersion, '2.6.3') < 0) {
effectiveCurrentVersion = '2.6.3';
}
if (legacyTimetableGroupsPresent || legacyTimetableEntriesPresent) {
effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17';
}
return VERSIONED_MIGRATIONS.filter(function (migration) {
return compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0;
});
}
async function runMigrations(pool, options) {
// Only run migrations that are newer than the installed schema version and not beyond the app version.
const pendingMigrations = await getPendingMigrations(pool, options);
for (const migration of pendingMigrations) {
await migration.run(pool);
}
}
module.exports = {
appVersion: appVersion,
getPendingMigrations: getPendingMigrations,
runMigrations: runMigrations,
detectSchemaVersion: detectSchemaVersion,
recordSchemaVersion: recordSchemaVersion,
compareVersions: compareVersions
};