From ad802ca279810e4ac6f6dd073b0695cae88c2ecf Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sat, 10 Jan 2026 15:25:05 -0800 Subject: [PATCH] Split up last_active_at migration --- .../migration.sql | 91 ------------------- .../migration.sql | 30 ++++++ .../migration.sql | 12 +++ .../migration.sql | 53 +++++++++++ .../migration.sql | 4 + .../migration.sql | 12 +++ .../migration.sql | 7 ++ apps/backend/src/auto-migrations/index.tsx | 4 + 8 files changed, 122 insertions(+), 91 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260101000001_backfill_user_last_active_at/migration.sql create mode 100644 apps/backend/prisma/migrations/20260101000002_create_temporary_session_backfill_index/migration.sql create mode 100644 apps/backend/prisma/migrations/20260101000003_backfill_session_last_active_at/migration.sql create mode 100644 apps/backend/prisma/migrations/20260101000004_drop_session_backfill_index/migration.sql create mode 100644 apps/backend/prisma/migrations/20260101000005_backfill_orphaned_rows/migration.sql create mode 100644 apps/backend/prisma/migrations/20260101000006_set_last_active_at_not_null/migration.sql diff --git a/apps/backend/prisma/migrations/20260101000000_add_last_active_at_columns/migration.sql b/apps/backend/prisma/migrations/20260101000000_add_last_active_at_columns/migration.sql index 8d22e7d0b..0ad45c08e 100644 --- a/apps/backend/prisma/migrations/20260101000000_add_last_active_at_columns/migration.sql +++ b/apps/backend/prisma/migrations/20260101000000_add_last_active_at_columns/migration.sql @@ -6,94 +6,3 @@ ALTER TABLE "ProjectUser" ADD COLUMN "lastActiveAt" TIMESTAMP(3); -- Add lastActiveAt and lastActiveAtIpInfo columns to ProjectUserRefreshToken table ALTER TABLE "ProjectUserRefreshToken" ADD COLUMN "lastActiveAt" TIMESTAMP(3); ALTER TABLE "ProjectUserRefreshToken" ADD COLUMN "lastActiveAtIpInfo" JSONB; - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL --- Backfill ProjectUser.lastActiveAt from Events table (using $user-activity events) -WITH to_update AS ( - SELECT pu."tenancyId", pu."projectUserId" - FROM "ProjectUser" pu - JOIN "Tenancy" t ON t."id" = pu."tenancyId" - WHERE pu."lastActiveAt" IS NULL - LIMIT 1000 -), -event_activity AS ( - SELECT - t."id" AS "tenancyId", - e."data"->>'userId' AS "userId", - MAX(e."eventStartedAt") AS "lastActiveAt" - FROM "Event" e - JOIN "Tenancy" t ON t."projectId" = e."data"->>'projectId' - AND t."branchId" = COALESCE(e."data"->>'branchId', 'main') - WHERE '$user-activity' = ANY(e."systemEventTypeIds"::text[]) - AND e."data"->>'userId' IS NOT NULL - AND EXISTS (SELECT 1 FROM to_update tu WHERE tu."tenancyId" = t."id" AND tu."projectUserId"::text = e."data"->>'userId') - GROUP BY t."id", e."data"->>'userId' -), -updated AS ( - UPDATE "ProjectUser" pu - SET "lastActiveAt" = COALESCE(ea."lastActiveAt", pu."createdAt") - FROM to_update tu - LEFT JOIN event_activity ea ON ea."tenancyId" = tu."tenancyId" AND ea."userId"::uuid = tu."projectUserId" - WHERE pu."tenancyId" = tu."tenancyId" AND pu."projectUserId" = tu."projectUserId" - RETURNING 1 -) -SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated; - --- SPLIT_STATEMENT_SENTINEL --- SINGLE_STATEMENT_SENTINEL --- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL --- Backfill ProjectUserRefreshToken.lastActiveAt and lastActiveAtIpInfo from Events table (using $session-activity events) -WITH to_update AS ( - SELECT rt."tenancyId", rt."id" - FROM "ProjectUserRefreshToken" rt - JOIN "Tenancy" t ON t."id" = rt."tenancyId" - WHERE rt."lastActiveAt" IS NULL - LIMIT 1000 -), --- Get the most recent session activity event for each session, along with its IP info -event_activity AS ( - SELECT DISTINCT ON (t."id", e."data"->>'sessionId') - t."id" AS "tenancyId", - e."data"->>'sessionId' AS "sessionId", - e."eventStartedAt" AS "lastActiveAt", - CASE - WHEN eip."id" IS NOT NULL THEN jsonb_build_object( - 'ip', eip."ip", - 'countryCode', eip."countryCode", - 'regionCode', eip."regionCode", - 'cityName', eip."cityName", - 'latitude', eip."latitude", - 'longitude', eip."longitude", - 'tzIdentifier', eip."tzIdentifier" - ) - ELSE NULL - END AS "ipInfo" - FROM "Event" e - JOIN "Tenancy" t ON t."projectId" = e."data"->>'projectId' - AND t."branchId" = COALESCE(e."data"->>'branchId', 'main') - LEFT JOIN "EventIpInfo" eip ON eip."id" = e."endUserIpInfoGuessId" - WHERE '$session-activity' = ANY(e."systemEventTypeIds"::text[]) - AND e."data"->>'sessionId' IS NOT NULL - AND EXISTS (SELECT 1 FROM to_update tu WHERE tu."tenancyId" = t."id" AND tu."id"::text = e."data"->>'sessionId') - ORDER BY t."id", e."data"->>'sessionId', e."eventStartedAt" DESC -), -updated AS ( - UPDATE "ProjectUserRefreshToken" rt - SET "lastActiveAt" = COALESCE(ea."lastActiveAt", rt."createdAt"), - "lastActiveAtIpInfo" = ea."ipInfo" - FROM to_update tu - LEFT JOIN event_activity ea ON ea."tenancyId" = tu."tenancyId" AND ea."sessionId"::uuid = tu."id" - WHERE rt."tenancyId" = tu."tenancyId" AND rt."id" = tu."id" - RETURNING 1 -) -SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated; - --- SPLIT_STATEMENT_SENTINEL --- Make columns NOT NULL with default NOW() for new rows -ALTER TABLE "ProjectUser" ALTER COLUMN "lastActiveAt" SET NOT NULL; -ALTER TABLE "ProjectUser" ALTER COLUMN "lastActiveAt" SET DEFAULT NOW(); - -ALTER TABLE "ProjectUserRefreshToken" ALTER COLUMN "lastActiveAt" SET NOT NULL; -ALTER TABLE "ProjectUserRefreshToken" ALTER COLUMN "lastActiveAt" SET DEFAULT NOW(); diff --git a/apps/backend/prisma/migrations/20260101000001_backfill_user_last_active_at/migration.sql b/apps/backend/prisma/migrations/20260101000001_backfill_user_last_active_at/migration.sql new file mode 100644 index 000000000..7f40be50d --- /dev/null +++ b/apps/backend/prisma/migrations/20260101000001_backfill_user_last_active_at/migration.sql @@ -0,0 +1,30 @@ +-- SINGLE_STATEMENT_SENTINEL +-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL +-- Backfill ProjectUser.lastActiveAt from Events table +-- Uses correlated subquery with existing index on (projectId, branchId, userId, eventStartedAt) +WITH to_update AS ( + SELECT pu."tenancyId", pu."projectUserId", t."projectId", t."branchId" + FROM "ProjectUser" pu + JOIN "Tenancy" t ON t."id" = pu."tenancyId" + WHERE pu."lastActiveAt" IS NULL + LIMIT 10000 +), +updated AS ( + UPDATE "ProjectUser" pu + SET "lastActiveAt" = COALESCE( + ( + SELECT e."eventStartedAt" + FROM "Event" e + WHERE e."data"->>'projectId' = tu."projectId" + AND COALESCE(e."data"->>'branchId', 'main') = tu."branchId" + AND e."data"->>'userId' = tu."projectUserId"::text + ORDER BY e."eventStartedAt" DESC + LIMIT 1 + ), + pu."createdAt" + ) + FROM to_update tu + WHERE pu."tenancyId" = tu."tenancyId" AND pu."projectUserId" = tu."projectUserId" + RETURNING 1 +) +SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated; diff --git a/apps/backend/prisma/migrations/20260101000002_create_temporary_session_backfill_index/migration.sql b/apps/backend/prisma/migrations/20260101000002_create_temporary_session_backfill_index/migration.sql new file mode 100644 index 000000000..258883405 --- /dev/null +++ b/apps/backend/prisma/migrations/20260101000002_create_temporary_session_backfill_index/migration.sql @@ -0,0 +1,12 @@ +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- Create temporary index for session backfill that matches our query exactly +-- The existing session index has branchId before userId/sessionId, which breaks index usage when we use COALESCE on branchId +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_event_session_backfill_temp +ON "Event" ( + (data->>'projectId'), + (COALESCE(data->>'branchId', 'main')), + (data->>'userId'), + (data->>'sessionId'), + "eventStartedAt" DESC +); diff --git a/apps/backend/prisma/migrations/20260101000003_backfill_session_last_active_at/migration.sql b/apps/backend/prisma/migrations/20260101000003_backfill_session_last_active_at/migration.sql new file mode 100644 index 000000000..81bdbf87b --- /dev/null +++ b/apps/backend/prisma/migrations/20260101000003_backfill_session_last_active_at/migration.sql @@ -0,0 +1,53 @@ +-- SINGLE_STATEMENT_SENTINEL +-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL +-- Backfill ProjectUserRefreshToken.lastActiveAt and lastActiveAtIpInfo from Events table +-- Uses correlated subquery with temporary index on (projectId, COALESCE(branchId, 'main'), userId, sessionId, eventStartedAt DESC) +WITH to_update AS ( + SELECT rt."tenancyId", rt."id", rt."projectUserId", t."projectId", t."branchId" + FROM "ProjectUserRefreshToken" rt + JOIN "Tenancy" t ON t."id" = rt."tenancyId" + WHERE rt."lastActiveAt" IS NULL + LIMIT 10000 +), +updated AS ( + UPDATE "ProjectUserRefreshToken" rt + SET "lastActiveAt" = COALESCE( + ( + SELECT e."eventStartedAt" + FROM "Event" e + WHERE e."data"->>'projectId' = tu."projectId" + AND COALESCE(e."data"->>'branchId', 'main') = tu."branchId" + AND e."data"->>'userId' = tu."projectUserId"::text + AND e."data"->>'sessionId' = tu."id"::text + ORDER BY e."eventStartedAt" DESC + LIMIT 1 + ), + rt."createdAt" + ), + "lastActiveAtIpInfo" = ( + SELECT CASE + WHEN eip."id" IS NOT NULL THEN jsonb_build_object( + 'ip', eip."ip", + 'countryCode', eip."countryCode", + 'regionCode', eip."regionCode", + 'cityName', eip."cityName", + 'latitude', eip."latitude", + 'longitude', eip."longitude", + 'tzIdentifier', eip."tzIdentifier" + ) + ELSE NULL + END + FROM "Event" e + LEFT JOIN "EventIpInfo" eip ON eip."id" = e."endUserIpInfoGuessId" + WHERE e."data"->>'projectId' = tu."projectId" + AND COALESCE(e."data"->>'branchId', 'main') = tu."branchId" + AND e."data"->>'userId' = tu."projectUserId"::text + AND e."data"->>'sessionId' = tu."id"::text + ORDER BY e."eventStartedAt" DESC + LIMIT 1 + ) + FROM to_update tu + WHERE rt."tenancyId" = tu."tenancyId" AND rt."id" = tu."id" + RETURNING 1 +) +SELECT COUNT(*) > 0 AS should_repeat_migration FROM updated; diff --git a/apps/backend/prisma/migrations/20260101000004_drop_session_backfill_index/migration.sql b/apps/backend/prisma/migrations/20260101000004_drop_session_backfill_index/migration.sql new file mode 100644 index 000000000..f4c7eb7fd --- /dev/null +++ b/apps/backend/prisma/migrations/20260101000004_drop_session_backfill_index/migration.sql @@ -0,0 +1,4 @@ +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- Drop the temporary session backfill index +DROP INDEX CONCURRENTLY IF EXISTS idx_event_session_backfill_temp; diff --git a/apps/backend/prisma/migrations/20260101000005_backfill_orphaned_rows/migration.sql b/apps/backend/prisma/migrations/20260101000005_backfill_orphaned_rows/migration.sql new file mode 100644 index 000000000..0ff3662ab --- /dev/null +++ b/apps/backend/prisma/migrations/20260101000005_backfill_orphaned_rows/migration.sql @@ -0,0 +1,12 @@ +-- Handle orphaned rows (rows without a matching Tenancy) by setting lastActiveAt to createdAt +-- These rows can't be backfilled from Events since we need Tenancy to find the right project/branch +UPDATE "ProjectUser" pu +SET "lastActiveAt" = pu."createdAt" +WHERE pu."lastActiveAt" IS NULL + AND NOT EXISTS (SELECT 1 FROM "Tenancy" t WHERE t."id" = pu."tenancyId"); + +UPDATE "ProjectUserRefreshToken" rt +SET "lastActiveAt" = rt."createdAt" +WHERE rt."lastActiveAt" IS NULL + AND NOT EXISTS (SELECT 1 FROM "Tenancy" t WHERE t."id" = rt."tenancyId"); + diff --git a/apps/backend/prisma/migrations/20260101000006_set_last_active_at_not_null/migration.sql b/apps/backend/prisma/migrations/20260101000006_set_last_active_at_not_null/migration.sql new file mode 100644 index 000000000..9d4c93291 --- /dev/null +++ b/apps/backend/prisma/migrations/20260101000006_set_last_active_at_not_null/migration.sql @@ -0,0 +1,7 @@ +-- Make columns NOT NULL with default NOW() for new rows +ALTER TABLE "ProjectUser" ALTER COLUMN "lastActiveAt" SET NOT NULL; +ALTER TABLE "ProjectUser" ALTER COLUMN "lastActiveAt" SET DEFAULT NOW(); + +ALTER TABLE "ProjectUserRefreshToken" ALTER COLUMN "lastActiveAt" SET NOT NULL; +ALTER TABLE "ProjectUserRefreshToken" ALTER COLUMN "lastActiveAt" SET DEFAULT NOW(); + diff --git a/apps/backend/src/auto-migrations/index.tsx b/apps/backend/src/auto-migrations/index.tsx index fb0b0da08..612558be1 100644 --- a/apps/backend/src/auto-migrations/index.tsx +++ b/apps/backend/src/auto-migrations/index.tsx @@ -132,6 +132,10 @@ export async function applyMigrations(options: { const isSingleStatement = statement.includes('SINGLE_STATEMENT_SENTINEL'); const isConditionallyRepeatMigration = statement.includes('CONDITIONALLY_REPEAT_MIGRATION_SENTINEL'); + if (isConditionallyRepeatMigration && !isSingleStatement) { + throw new StackAssertionError("CONDITIONALLY_REPEAT_MIGRATION_SENTINEL requires SINGLE_STATEMENT_SENTINEL", { statement }); + } + log(` |> Running statement${isSingleStatement ? "" : "s"}${runOutside ? " outside of transaction" : ""}: ${statement.replace(/(\n|\s)/gm, " ").slice(0, 20)}...`); const txOrPrismaClient = runOutside ? options.prismaClient : tx;