diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql index e569b3230..d4e9c825c 100644 --- a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/migration.sql @@ -3,14 +3,17 @@ -- endedAt = NULL. The status-agnostic isSubscriptionInEffect predicate reads -- NULL as "entitled forever", so these rows must be closed out. -- --- endedAt = LEAST(currentPeriodEnd, NOW()) mirrors getEndedAtForSync's +-- endedAt = LEAST(currentPeriodEnd, now) mirrors getEndedAtForSync's -- fallback when Stripe omits ended_at: the paid-through boundary if it's --- already past, otherwise now. +-- already past, otherwise now. NOW() AT TIME ZONE 'UTC' yields a naive UTC +-- timestamp matching the timezone-less columns (which the app always writes +-- as UTC); bare NOW() would be shifted by the session offset on non-UTC +-- sessions when cast into "endedAt". -- -- No batching/temp-index machinery: the affected population was measured in -- production at 7 rows (2026-07-17), and a plain UPDATE only takes row locks -- on matching rows, so the seq scan is safe regardless of table size. UPDATE "Subscription" -SET "endedAt" = LEAST("currentPeriodEnd", NOW()) +SET "endedAt" = LEAST("currentPeriodEnd", NOW() AT TIME ZONE 'UTC') WHERE "status" IN ('canceled', 'incomplete_expired', 'unpaid') AND "endedAt" IS NULL; diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts index 07e30ff59..4b06f3c72 100644 --- a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/backfills-terminal-rows.ts @@ -19,7 +19,7 @@ const insertSubscription = async (sql: Sql, options: { ${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1, ${`sub_${id}`}, ${options.status}::"SubscriptionStatus", '2026-01-01', ${options.currentPeriodEnd}, - false, ${options.endedAt ?? null}, 'PURCHASE_PAGE', NOW(), NOW() + false, ${options.endedAt ?? null}, 'PURCHASE_PAGE', NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC' ) `; return id; @@ -40,22 +40,21 @@ export const preMigration = async (sql: Sql) => { const futurePeriodEnd = new Date('2100-01-01T00:00:00Z'); const futureCanceledId = await insertSubscription(sql, { tenancyId, status: 'canceled', currentPeriodEnd: futurePeriodEnd }); - // The DB's own clock, so the post-migration bound check compares - // apples-to-apples (the Subscription timestamp columns have no time zone, - // so JS-side Date comparisons would be off by the session's tz offset). - const [{ now: dbNowBeforeMigration }] = await sql`SELECT NOW() AS now`; - - return { tenancyId, canceledId, incompleteExpiredId, unpaidId, futureCanceledId, dbNowBeforeMigration: dbNowBeforeMigration as Date }; + return { tenancyId, canceledId, incompleteExpiredId, unpaidId, futureCanceledId }; }; export const postMigration = async (sql: Sql, ctx: Awaited>) => { - // Compare in SQL so stored values never round-trip through JS Dates. + // All comparisons stay server-side in the naive-UTC domain (createdAt was + // inserted with NOW() AT TIME ZONE 'UTC', matching the migration's write) — + // values never round-trip through JS Dates, whose parse/serialize is + // tz-offset-asymmetric for timezone-less columns, and no JS clock is + // compared against the server clock. const rows = await sql` SELECT "id", ("endedAt" IS NOT NULL) AS "hasEndedAt", ("endedAt" = "currentPeriodEnd") AS "matchesPeriodEnd", - ("endedAt" >= ${ctx.dbNowBeforeMigration} AND "endedAt" <= NOW() AND "endedAt" < "currentPeriodEnd") AS "cappedAtNow" + ("endedAt" >= "createdAt" AND "endedAt" <= NOW() AT TIME ZONE 'UTC' AND "endedAt" < "currentPeriodEnd") AS "cappedAtNow" FROM "Subscription" WHERE "tenancyId" = ${ctx.tenancyId}::uuid `; const byId = new Map(rows.map((row) => [row.id, row])); diff --git a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts index f955c5ea9..1eb523ad0 100644 --- a/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts +++ b/apps/backend/prisma/migrations/20260717000000_backfill_terminal_subscription_ended_at/tests/leaves-other-rows-untouched.ts @@ -18,7 +18,7 @@ const insertSubscription = async (sql: Sql, options: { ${sql.json({ displayName: 'Test Product', customerType: 'team' })}, 1, ${`sub_${id}`}, ${options.status}::"SubscriptionStatus", '2026-01-01', '2026-02-01', - false, ${options.endedAt}, 'PURCHASE_PAGE', NOW(), NOW() + false, ${options.endedAt}, 'PURCHASE_PAGE', NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC' ) `; return id;