Split up last_active_at migration

This commit is contained in:
Konstantin Wohlwend 2026-01-10 15:25:05 -08:00
parent a809ac16d6
commit ad802ca279
8 changed files with 122 additions and 91 deletions

View File

@ -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();

View File

@ -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;

View File

@ -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
);

View File

@ -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;

View File

@ -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;

View File

@ -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");

View File

@ -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();

View File

@ -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;