From b0367ca48d42862292d821dedebd9b1538ee98aa Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 17 Jul 2026 10:24:33 -0700 Subject: [PATCH] Use naive-UTC NOW() in terminal-subscription backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Subscription timestamp columns are timezone-less and the app always writes them as UTC, but bare NOW() is a timestamptz, so LEAST() and the assignment would go through implicit session-timezone casts — on a non-UTC session the capped-at-now branch would store a value shifted by the offset. NOW() AT TIME ZONE 'UTC' stays in the naive-UTC domain throughout. The tests now keep every timestamp comparison server-side in that same domain (lower bound from the row's own createdAt instead of a JS-captured clock): the driver's Date parse/serialize is tz-offset-asymmetric for timezone-less columns, and the JS clock can't be assumed to agree with the DB server clock. --- .../migration.sql | 9 ++++++--- .../tests/backfills-terminal-rows.ts | 17 ++++++++--------- .../tests/leaves-other-rows-untouched.ts | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) 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;