Use naive-UTC NOW() in terminal-subscription backfill
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

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.
This commit is contained in:
Bilal Godil 2026-07-17 10:24:33 -07:00
parent de526eaa31
commit b0367ca48d
3 changed files with 15 additions and 13 deletions

View File

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

View File

@ -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<ReturnType<typeof preMigration>>) => {
// 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]));

View File

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