From 3511936a291e58704948c8c9f8ecdca844257ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 21 Apr 2026 16:20:48 +0200 Subject: [PATCH] refactor: move SchemaMigration responsibilities to Flyway migrations V13 and V14 - V13: create oauth2_authorized_client table and SPRING_SESSION_ATTRIBUTES upsert trigger - V14: normalize oauth2_authorized_client timestamp columns (one-time data migration) - SchemaMigration now only retains legacy backward-compat code for pre-Flyway databases - Remove normalizeAuthorizedClientTimestamps() and ensureSessionAttributeUpsertTrigger() from SchemaMigration - Add clear comment explaining SchemaMigration is legacy code only --- .../imgfloat/config/SchemaMigration.java | 73 +------------------ ...auth_client_and_session_upsert_trigger.sql | 28 +++++++ ...V14__normalize_oauth_client_timestamps.sql | 27 +++++++ 3 files changed, 59 insertions(+), 69 deletions(-) create mode 100644 src/main/resources/db/migration/V13__oauth_client_and_session_upsert_trigger.sql create mode 100644 src/main/resources/db/migration/V14__normalize_oauth_client_timestamps.sql diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index b63f0c1..173315e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -12,7 +12,9 @@ import org.springframework.stereotype.Component; @Component public class SchemaMigration implements ApplicationRunner { - // TODO: Code smell Runtime schema migration logic duplicates Flyway responsibilities and is difficult to reason about/test. + // Legacy backward-compatibility runner for databases that predate the Flyway migration + // baseline. Each method is idempotent so it is harmless on up-to-date databases. New + // schema changes must go into a versioned Flyway script under db/migration, not here. private static final Logger LOG = LoggerFactory.getLogger(SchemaMigration.class); @@ -24,32 +26,10 @@ public class SchemaMigration implements ApplicationRunner { @Override public void run(ApplicationArguments args) { - ensureSessionAttributeUpsertTrigger(); ensureChannelCanvasColumns(); ensureAssetTables(); ensureMarketplaceScriptHeartsTable(); ensureAuthorizedClientTable(); - normalizeAuthorizedClientTimestamps(); - } - - private void ensureSessionAttributeUpsertTrigger() { - try { - jdbcTemplate.execute( - """ - CREATE TRIGGER IF NOT EXISTS SPRING_SESSION_ATTRIBUTES_UPSERT - BEFORE INSERT ON SPRING_SESSION_ATTRIBUTES - FOR EACH ROW - BEGIN - DELETE FROM SPRING_SESSION_ATTRIBUTES - WHERE SESSION_PRIMARY_ID = NEW.SESSION_PRIMARY_ID - AND ATTRIBUTE_NAME = NEW.ATTRIBUTE_NAME; - END; - """ - ); - LOG.info("Ensured SPRING_SESSION_ATTRIBUTES upsert trigger exists"); - } catch (DataAccessException ex) { - LOG.warn("Unable to ensure SPRING_SESSION_ATTRIBUTES upsert trigger", ex); - } } private void ensureChannelCanvasColumns() { @@ -333,6 +313,7 @@ public class SchemaMigration implements ApplicationRunner { } private void ensureAuthorizedClientTable() { + // Retained for databases predating V13. V13 creates this table for new installations. try { jdbcTemplate.execute( """ @@ -350,54 +331,8 @@ public class SchemaMigration implements ApplicationRunner { ) """ ); - LOG.info("Ensured oauth2_authorized_client table exists"); } catch (DataAccessException ex) { LOG.warn("Unable to ensure oauth2_authorized_client table", ex); } } - - private void normalizeAuthorizedClientTimestamps() { - normalizeTimestampColumn("access_token_issued_at"); - normalizeTimestampColumn("access_token_expires_at"); - normalizeTimestampColumn("refresh_token_issued_at"); - } - - private void normalizeTimestampColumn(String column) { - try { - int updated = jdbcTemplate.update( - "UPDATE oauth2_authorized_client " + - "SET " + - column + - " = CASE " + - "WHEN " + - column + - " LIKE '%-%' THEN CAST(strftime('%s', " + - column + - ") AS INTEGER) * 1000 " + - "WHEN typeof(" + - column + - ") = 'text' AND " + - column + - " GLOB '[0-9]*' THEN CAST(" + - column + - " AS INTEGER) " + - "WHEN typeof(" + - column + - ") = 'integer' THEN " + - column + - " " + - "ELSE " + - column + - " END " + - "WHERE " + - column + - " IS NOT NULL" - ); - if (updated > 0) { - LOG.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column); - } - } catch (DataAccessException ex) { - LOG.warn("Unable to normalize oauth2_authorized_client.{} timestamps", column, ex); - } - } } diff --git a/src/main/resources/db/migration/V13__oauth_client_and_session_upsert_trigger.sql b/src/main/resources/db/migration/V13__oauth_client_and_session_upsert_trigger.sql new file mode 100644 index 0000000..f0feac7 --- /dev/null +++ b/src/main/resources/db/migration/V13__oauth_client_and_session_upsert_trigger.sql @@ -0,0 +1,28 @@ +-- OAuth2 authorized client table for storing Twitch OAuth tokens at rest (AES-256-GCM encrypted). +-- This table is managed by SQLiteOAuth2AuthorizedClientService; Spring Session's JDBC +-- auto-initialization is disabled (spring.session.jdbc.initialize-schema=never) so the +-- table must be created here. +CREATE TABLE IF NOT EXISTS oauth2_authorized_client ( + client_registration_id VARCHAR(100) NOT NULL, + principal_name VARCHAR(200) NOT NULL, + access_token_type VARCHAR(100), + access_token_value TEXT, + access_token_issued_at INTEGER, + access_token_expires_at INTEGER, + access_token_scopes VARCHAR(1000), + refresh_token_value TEXT, + refresh_token_issued_at INTEGER, + PRIMARY KEY (client_registration_id, principal_name) +); + +-- SQLite does not support INSERT OR REPLACE semantics for Spring Session's attribute writes. +-- This trigger deletes any existing row for the same (session, attribute) pair before each +-- INSERT, effectively converting INSERT to an UPSERT. +CREATE TRIGGER IF NOT EXISTS SPRING_SESSION_ATTRIBUTES_UPSERT +BEFORE INSERT ON spring_session_attributes +FOR EACH ROW +BEGIN + DELETE FROM spring_session_attributes + WHERE session_primary_id = NEW.session_primary_id + AND attribute_name = NEW.attribute_name; +END; diff --git a/src/main/resources/db/migration/V14__normalize_oauth_client_timestamps.sql b/src/main/resources/db/migration/V14__normalize_oauth_client_timestamps.sql new file mode 100644 index 0000000..27949bb --- /dev/null +++ b/src/main/resources/db/migration/V14__normalize_oauth_client_timestamps.sql @@ -0,0 +1,27 @@ +-- One-time data migration: normalize oauth2_authorized_client timestamp columns from +-- ISO-8601 strings or text epoch values to integer milliseconds-since-epoch. +-- This corrects records written by earlier versions of the application that stored +-- Instant values as strings rather than longs. +UPDATE oauth2_authorized_client +SET access_token_issued_at = CASE + WHEN access_token_issued_at LIKE '%-%' THEN CAST(strftime('%s', access_token_issued_at) AS INTEGER) * 1000 + WHEN typeof(access_token_issued_at) = 'text' AND access_token_issued_at GLOB '[0-9]*' THEN CAST(access_token_issued_at AS INTEGER) + ELSE access_token_issued_at +END +WHERE access_token_issued_at IS NOT NULL; + +UPDATE oauth2_authorized_client +SET access_token_expires_at = CASE + WHEN access_token_expires_at LIKE '%-%' THEN CAST(strftime('%s', access_token_expires_at) AS INTEGER) * 1000 + WHEN typeof(access_token_expires_at) = 'text' AND access_token_expires_at GLOB '[0-9]*' THEN CAST(access_token_expires_at AS INTEGER) + ELSE access_token_expires_at +END +WHERE access_token_expires_at IS NOT NULL; + +UPDATE oauth2_authorized_client +SET refresh_token_issued_at = CASE + WHEN refresh_token_issued_at LIKE '%-%' THEN CAST(strftime('%s', refresh_token_issued_at) AS INTEGER) * 1000 + WHEN typeof(refresh_token_issued_at) = 'text' AND refresh_token_issued_at GLOB '[0-9]*' THEN CAST(refresh_token_issued_at AS INTEGER) + ELSE refresh_token_issued_at +END +WHERE refresh_token_issued_at IS NOT NULL;