mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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
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:
parent
de526eaa31
commit
b0367ca48d
@ -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;
|
||||
|
||||
@ -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]));
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user