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
This commit is contained in:
2026-04-21 16:20:48 +02:00
parent 1a2e2344da
commit 3511936a29
3 changed files with 59 additions and 69 deletions
@@ -12,7 +12,9 @@ import org.springframework.stereotype.Component;
@Component @Component
public class SchemaMigration implements ApplicationRunner { 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); private static final Logger LOG = LoggerFactory.getLogger(SchemaMigration.class);
@@ -24,32 +26,10 @@ public class SchemaMigration implements ApplicationRunner {
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
ensureSessionAttributeUpsertTrigger();
ensureChannelCanvasColumns(); ensureChannelCanvasColumns();
ensureAssetTables(); ensureAssetTables();
ensureMarketplaceScriptHeartsTable(); ensureMarketplaceScriptHeartsTable();
ensureAuthorizedClientTable(); 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() { private void ensureChannelCanvasColumns() {
@@ -333,6 +313,7 @@ public class SchemaMigration implements ApplicationRunner {
} }
private void ensureAuthorizedClientTable() { private void ensureAuthorizedClientTable() {
// Retained for databases predating V13. V13 creates this table for new installations.
try { try {
jdbcTemplate.execute( jdbcTemplate.execute(
""" """
@@ -350,54 +331,8 @@ public class SchemaMigration implements ApplicationRunner {
) )
""" """
); );
LOG.info("Ensured oauth2_authorized_client table exists");
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
LOG.warn("Unable to ensure oauth2_authorized_client table", 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);
}
}
} }
@@ -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;
@@ -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;