feat(backend): pin server timezone to UTC with startup assertion

Belt-and-suspenders, not a bug fix: storage (timestamptz) and timer math are already tz-independent. Add SERVER_TZ env (default UTC) via getServerTimezone(); db/client.js pins the DB session timezone (reads env directly to avoid an import cycle); server.js pins process.env.TZ and asserts at boot that the DB session matches (logs [tz] or a loud warning). Keeps any future date_trunc/::date reporting deterministic and surfaces a misconfigured server early. Documented in backend/CLAUDE.md + .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:27:16 +08:00
parent 495eb98787
commit 529a38ae3f
5 changed files with 58 additions and 2 deletions

View File

@@ -6,6 +6,12 @@ INTERNAL_HOST=127.0.0.1
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/halobestie
# Server timezone. Pins the DB session + Node process to one zone. Leave as UTC
# in all environments — storage (timestamptz) and timer math are tz-independent,
# this just keeps any future date_trunc/::date-style SQL deterministic. The
# backend asserts the DB session matches this at startup.
SERVER_TZ=UTC
# Valkey / Redis
VALKEY_URL=redis://localhost:6379

View File

@@ -58,6 +58,16 @@ Two distinct knob-types exist; do not conflate them:
When a new value needs to flow from CC → app, prefer DB. When it's a deploy-fixed contract (e.g. heartbeat cadence the apps must honor, Xendit credentials, callback tokens), prefer env. CC inputs that depend on env values (e.g. min/max validation) read the env-derived value via the same config endpoint that surfaces the DB value, and the PATCH route validates against it.
## Timezone
**The backend is UTC end-to-end, and that is independent of the server/OS timezone.**
- All timestamp columns are `TIMESTAMPTZ`, which stores an absolute UTC instant (no per-row zone). Storage does NOT depend on the session/server timezone.
- All timestamp writes use server-computed instants (`NOW()`, `NOW() + interval`), never app-supplied wall-clock. There is no session-tz-dependent SQL (`date_trunc` / `::date` / `CURRENT_DATE` / `AT TIME ZONE`) anywhere today, so correctness does not rely on the timezone setting.
- The `postgres` driver returns JS `Date` (an absolute instant); Fastify serializes it via `.toISOString()`, so the API always emits ISO-8601 with a `Z`. Flutter parses that to a UTC `DateTime` and `.toLocal()`s **only at display time**. Rule for the apps: store/transport UTC, convert to local only when rendering a wall-clock.
`SERVER_TZ` (env, default `UTC`) is **belt-and-suspenders**, not a fix for any live bug: `db/client.js` pins the DB session timezone and `server.js` pins `process.env.TZ` to it, then asserts at boot that the DB session matches (logs `[tz] …` / a loud warning otherwise). This keeps any *future* `date_trunc`/`::date`-style reporting deterministic and surfaces a misconfigured server early. Getter: `getServerTimezone()` in `config.service.js` (`db/client.js` reads the env directly to avoid an import cycle — keep the `UTC` default in sync). The thing that genuinely matters operationally is NTP clock sync, not the timezone — a wrong wall-clock breaks `NOW()` and timers; a wrong timezone does not.
## FCM Channel Convention
Single channel `halobestie_chat_v2` is shared by both apps and ships the branded `halobestie_notif.ogg` sound. Backend FCM payloads should always target this channel ID via `android.notification.channelId`:

View File

@@ -4,7 +4,13 @@ let sql
export const getDb = () => {
if (!sql) {
sql = postgres(process.env.DATABASE_URL)
// Pin the session timezone so timestamptz I/O and any future
// session-tz-dependent SQL (date_trunc / ::date / CURRENT_DATE) are
// deterministic regardless of the DB server's default. Defaults to UTC.
// Mirrors getServerTimezone() in config.service.js — kept inline here to
// avoid a client.js <-> config.service.js import cycle.
const timezone = (process.env.SERVER_TZ || 'UTC').trim()
sql = postgres(process.env.DATABASE_URL, { connection: { timezone } })
}
return sql
}

View File

@@ -11,13 +11,33 @@ import {
import { initFirebase } from './plugins/firebase.js'
import { restoreActiveTimers } from './services/session-timer.service.js'
import { expireStalePaymentRequests, registerPairingSubscriber } from './services/payment.service.js'
import { getXenditConfig } from './services/config.service.js'
import { getXenditConfig, getServerTimezone } from './services/config.service.js'
import { getDb } from './db/client.js'
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1'
const start = async () => {
// Timezone assurance. Storage (timestamptz) and our instant-based timer math
// are timezone-independent, so this is belt-and-suspenders, not a fix for a
// live bug: it pins the Node process timezone for any Date formatting and
// then asserts the DB session timezone matches, so a misconfigured server/DB
// surfaces loudly at boot instead of silently skewing future date_trunc /
// ::date style queries. Defaults to UTC; override via SERVER_TZ.
const serverTz = getServerTimezone()
process.env.TZ = serverTz
const [dbTz] = await getDb()`SHOW timezone`
if (dbTz?.TimeZone !== serverTz) {
console.warn(
`[tz] WARNING: DB session timezone is "${dbTz?.TimeZone}" but SERVER_TZ="${serverTz}". ` +
'timestamptz storage is unaffected, but session-tz-dependent SQL may skew. ' +
'Check the DB server default / connection options.'
)
} else {
console.log(`[tz] process + DB session pinned to ${serverTz}`)
}
// Phase 5: fail fast if XENDIT_ENABLED=true without the required credentials.
// Bad config explodes at startup rather than at the first /payment-requests POST.
const xc = getXenditConfig()

View File

@@ -114,6 +114,20 @@ export const getMitraHeartbeatCadenceSeconds = () => {
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
}
// Server timezone. Defaults to UTC and should essentially never be changed —
// see backend/CLAUDE.md "Timezone" note. timestamptz storage and all of our
// instant-based timer math are timezone-INDEPENDENT, so this is belt-and-
// suspenders: it pins the DB session + Node process to one zone so that any
// FUTURE session-tz-dependent SQL (date_trunc / ::date / CURRENT_DATE) and any
// stray local-time Date formatting stay deterministic across deploys.
// NOTE: db/client.js reads `process.env.SERVER_TZ || 'UTC'` directly (it cannot
// import this module without a cycle); keep the default in sync.
export const getServerTimezone = () => {
const raw = process.env.SERVER_TZ
if (!raw || raw.trim() === '') return 'UTC'
return raw.trim()
}
// --- Valkey availability mirror — env-driven cadences ---
//
// Per requirement/valkey-online-mirror-plan.md. All three are operational