Files
pulse-signage/src/db/migrations.js
T

432 lines
17 KiB
JavaScript

const { version: appVersion } = require('#root/package.json');
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
`);
}
// Seed the singleton player row used by the current one-player model.
// For multi-player support, this seed and the hardcoded screen_id/player_id mapping will need to be replaced.
await pool.query(`INSERT IGNORE INTO d_players (device_id) VALUES ('1')`);
// 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.
const [screenPlayerFkRows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND CONSTRAINT_NAME = 'fk_screens_player'`
);
if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) {
await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE 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');
}
}
];
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 ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) {
if (await columnExists(pool, tableName, columnName)) {
return;
}
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
}
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ?`,
[tableName, constraintName]
);
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
return;
}
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
}
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 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 runMigrations(pool, options) {
// Only run migrations that are newer than the installed schema version and not beyond the app version.
const targetVersion = String(appVersion || '0.0.0').trim();
const currentVersion = String(options && options.currentVersion || '0.0.0').trim();
for (const migration of VERSIONED_MIGRATIONS) {
if (compareVersions(migration.version, currentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) {
await migration.run(pool);
}
}
}
module.exports = {
appVersion: appVersion,
runMigrations: runMigrations
};