fix(jobs): break consolidation import cycle that crashed the worker at boot

The reminder worker failed to start in the production bundle with
`ReferenceError: Cannot access 'l' before initialization`, taking every
nightly pg-boss job down with it (score jobs + dense-intraday retention).

Root cause is a value-level import cycle between `consolidation-base.ts`
and `drain-per-sample-cumulative.ts`: each imported values from the other.
The bundler merges the tightly-cyclic pair into one module body and hoists
`DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS` ahead of
the constant it reads, so the eager read hits a temporal dead zone. It only
surfaced in the production build because dev/test run the modules unbundled.

Hoist the shared leaf — `CONSOLIDATION_GRACE_CUTOFF_HOURS`, the `PerSampleRow`
shape, and the timezone day-math helpers — into a new dependency-free module
`consolidation-tz.ts`. Both sides now import from the leaf, removing the cycle
at its root. Re-export the moved names from their former homes so every
existing import site resolves unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 01:01:21 +02:00
1 parent d105849026
commit fbab8cfd30
3 files changed
+191 -143

No files matched your search

+11 -13
View File
@@ -19,10 +19,12 @@
* supplies its reducer/types and keeps its own public summary shape.
*
* The timezone day-math helpers (`dayKeyForUserTz`,
* `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`) live in
* `drain-per-sample-cumulative.ts` and are imported here to avoid a
* cyclic dependency — that module is the historical home the other two
* already import them from.
* `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`), the
* `PerSampleRow` shape, and `CONSOLIDATION_GRACE_CUTOFF_HOURS` all live
* in the dependency-free leaf `consolidation-tz.ts`. Both this module
* and `drain-per-sample-cumulative.ts` import them from there, so there
* is no value-level cycle between the two — a cycle would otherwise trip
* a module-init TDZ in the production bundle.
*/
import type {
MeasurementType,
@@ -31,19 +33,15 @@ import type {
} from "@/generated/prisma/client";
import {
CONSOLIDATION_GRACE_CUTOFF_HOURS,
canonicalDailyTimestamp,
dayKeyForUserTz,
type PerSampleRow,
} from "./drain-per-sample-cumulative";
} from "./consolidation-tz";
/**
* Canonical grace window shared by the cumulative + mean drains. Rows
* whose `measuredAt` is newer than `now() - CONSOLIDATION_GRACE_CUTOFF_HOURS`
* stay raw so today's still-in-flight watch syncs surface in the live
* "today" view. 36 hours covers the previous calendar day plus a
* trailing sync window for watches that weren't worn at midnight.
*/
export const CONSOLIDATION_GRACE_CUTOFF_HOURS = 36;
// Re-export the shared grace constant so the established
// `consolidation-base` import surface stays unchanged for callers.
export { CONSOLIDATION_GRACE_CUTOFF_HOURS };
/** Fallback timezone for users with no `User.timezone` set. */
const DEFAULT_TIMEZONE = "Europe/Berlin";
+158
View File
@@ -0,0 +1,158 @@
/**
* Leaf timezone day-math + per-sample-row shape shared by the
* consolidation drains.
*
* This module owns the primitives that both `consolidation-base.ts` (the
* shared `runConsolidation` driver) and `drain-per-sample-cumulative.ts`
* (the cumulative drain) need. It deliberately imports nothing from
* either of those — it is a dependency-free leaf so the two can both
* reach the helpers without forming an import cycle.
*
* History: `dayKeyForUserTz`, `canonicalDailyTimestamp`, `localStartOfDay`,
* `localDayWindow`, the grace constant, and the `PerSampleRow` shape used
* to live in `drain-per-sample-cumulative.ts`. `consolidation-base.ts`
* imported them from there while `drain` imported the runner back — a
* value-level cycle. Bundled for production (Turbopack merges tightly
* cyclic modules into one body), the eager re-export
* `const DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS`
* was hoisted ahead of the constant it read, throwing a
* `ReferenceError: Cannot access '…' before initialization` at worker
* boot. Hoisting the shared leaf into this module removes the cycle at
* its root; `drain-per-sample-cumulative.ts` re-exports these names so
* every existing import site keeps working unchanged.
*/
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Canonical grace window shared by the cumulative + mean drains. Rows
* whose `measuredAt` is newer than `now() - CONSOLIDATION_GRACE_CUTOFF_HOURS`
* stay raw so today's still-in-flight watch syncs surface in the live
* "today" view. 36 hours covers the previous calendar day plus a
* trailing sync window for watches that weren't worn at midnight.
*/
export const CONSOLIDATION_GRACE_CUTOFF_HOURS = 36;
/**
* Per-sample row shape the drains scan and bucket. Exposed for unit
* testing the bucketing semantics without booting Prisma.
*/
export interface PerSampleRow {
id: string;
type: MeasurementType;
value: number;
measuredAt: Date;
externalId: string | null;
/**
* Optional — only the mean-consolidation pass selects `unit` so it can
* read the canonical unit straight off the day's rows rather than
* issuing a separate query. The cumulative drain leaves it unselected.
*/
unit?: string;
}
/**
* Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given
* timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice
* the mood-entries path locked in v1.4.25 W7b so iOS-side and
* server-side day-keys round-trip byte-identically.
*/
export function dayKeyForUserTz(date: Date, tz: string): string {
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
}
/**
* Read the IANA-zone offset (in minutes east of UTC) at a given
* instant. Returns 0 for UTC and any zone the shortOffset formatter
* can't resolve (defensive — Node 22's full-icu build covers every
* zone we care about).
*/
function tzOffsetMinutesAt(instant: Date, tz: string): number {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour12: false,
hour: "2-digit",
minute: "2-digit",
timeZoneName: "shortOffset",
});
const parts = fmt.formatToParts(instant);
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
if (!match) return 0;
const sign = match[1] === "+" ? 1 : -1;
const hours = Number.parseInt(match[2], 10);
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
return sign * (hours * 60 + minutes);
}
/**
* Compute the canonical timestamp for a calendar-day key in the user's
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
* — matches the Withings activity sync convention (one daily row per
* type, anchored to midday so the row sorts cleanly between same-day
* spot samples). The string returned by `toISOString()` is UTC.
*/
export function canonicalDailyTimestamp(dateKey: string, tz: string): Date {
// Compute the UTC offset for noon-local of the given day. We don't
// have a lightweight TZ-math library on the server, so the trick is:
// build "12:00 UTC of the day", read what wall-clock that shows in
// the target zone, then shift by the resulting offset.
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz);
// utcNoon represents 12:00 UTC. The user's local clock reads
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
// subtract the offset.
return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000);
}
/**
* v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00
* for a calendar-day key. Robust on DST transitions because the offset
* is read at the UTC-midnight instant of the day, and EU/US DST
* transitions happen at local 02:00 / 03:00 — so the offset at UTC
* midnight is unambiguous on every day of the year.
*
* Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on
* a 23-h spring-forward or 25-h fall-back day; the previous shape
* (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour
* of samples on two days per year.
*
* Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so
* the returned window covers the true local-day span — 23 h on
* spring-forward, 24 h on a regular day, 25 h on fall-back.
*/
export function localStartOfDay(dateKey: string, tz: string): Date {
// Anchor at 00:00 UTC of the day; the offset read at that instant
// is the same offset the local clock uses at midnight (DST
// transitions in EU/US happen at 02:00 / 03:00 local, not at the
// 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45,
// Pacific/Chatham UTC+12:45) the minute component is preserved.
const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`);
const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz);
// Local 00:00 at this date = UTC midnight - offset.
return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000);
}
/**
* v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a
* calendar-day key in the user's IANA timezone. The window honours
* DST so the drill-down branch returns the correct 23 / 24 / 25-hour
* span for transition days.
*
* Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING
* calendar day — `< dayEnd` is the canonical half-open bound used by
* the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate.
*/
export function localDayWindow(
dateKey: string,
tz: string,
): { dayStart: Date; dayEnd: Date } {
const dayStart = localStartOfDay(dateKey, tz);
// Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon
// sidesteps every DST edge case for the calendar increment). Then
// re-extract the ISO date slice — guaranteed to be the next-day key.
const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`);
nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1);
const nextDateKey = nextUtcNoon.toISOString().slice(0, 10);
const dayEnd = localStartOfDay(nextDateKey, tz);
return { dayStart, dayEnd };
}
@@ -40,11 +40,32 @@ import {
hkIdentifierForType,
} from "./apple-health-mapping";
import {
CONSOLIDATION_GRACE_CUTOFF_HOURS,
bucketRowsByDay,
runConsolidation,
type DayWriteOutcome,
} from "./consolidation-base";
import {
CONSOLIDATION_GRACE_CUTOFF_HOURS,
canonicalDailyTimestamp,
dayKeyForUserTz,
localDayWindow,
localStartOfDay,
type PerSampleRow,
} from "./consolidation-tz";
// Re-export the shared timezone day-math primitives + `PerSampleRow`
// shape so every established `drain-per-sample-cumulative` import site
// (the measurements route, the Apple-Health importer, the unit tests)
// keeps resolving them from here. The definitions moved to the
// dependency-free leaf `consolidation-tz.ts` to break the init-order
// cycle with `consolidation-base.ts`.
export {
canonicalDailyTimestamp,
dayKeyForUserTz,
localDayWindow,
localStartOfDay,
};
export type { PerSampleRow };
/** Prefix marking an already-collapsed daily-stats row. */
const DAILY_STATS_PREFIX = "stats:";
@@ -118,135 +139,6 @@ export interface DrainOptions {
cutoffHours?: number;
}
/**
* Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given
* timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice
* the mood-entries path locked in v1.4.25 W7b so iOS-side and
* server-side day-keys round-trip byte-identically.
*/
export function dayKeyForUserTz(date: Date, tz: string): string {
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
}
/**
* Read the IANA-zone offset (in minutes east of UTC) at a given
* instant. Returns 0 for UTC and any zone the shortOffset formatter
* can't resolve (defensive — Node 22's full-icu build covers every
* zone we care about).
*/
function tzOffsetMinutesAt(instant: Date, tz: string): number {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour12: false,
hour: "2-digit",
minute: "2-digit",
timeZoneName: "shortOffset",
});
const parts = fmt.formatToParts(instant);
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
if (!match) return 0;
const sign = match[1] === "+" ? 1 : -1;
const hours = Number.parseInt(match[2], 10);
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
return sign * (hours * 60 + minutes);
}
/**
* Compute the canonical timestamp for a calendar-day key in the user's
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
* — matches the Withings activity sync convention (one daily row per
* type, anchored to midday so the row sorts cleanly between same-day
* spot samples). The string returned by `toISOString()` is UTC.
*/
export function canonicalDailyTimestamp(
dateKey: string,
tz: string,
): Date {
// Compute the UTC offset for noon-local of the given day. We don't
// have a lightweight TZ-math library on the server, so the trick is:
// build "12:00 UTC of the day", read what wall-clock that shows in
// the target zone, then shift by the resulting offset.
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz);
// utcNoon represents 12:00 UTC. The user's local clock reads
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
// subtract the offset.
return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000);
}
/**
* v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00
* for a calendar-day key. Robust on DST transitions because the offset
* is read at the UTC-midnight instant of the day, and EU/US DST
* transitions happen at local 02:00 / 03:00 — so the offset at UTC
* midnight is unambiguous on every day of the year.
*
* Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on
* a 23-h spring-forward or 25-h fall-back day; the previous shape
* (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour
* of samples on two days per year.
*
* Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so
* the returned window covers the true local-day span — 23 h on
* spring-forward, 24 h on a regular day, 25 h on fall-back.
*/
export function localStartOfDay(dateKey: string, tz: string): Date {
// Anchor at 00:00 UTC of the day; the offset read at that instant
// is the same offset the local clock uses at midnight (DST
// transitions in EU/US happen at 02:00 / 03:00 local, not at the
// 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45,
// Pacific/Chatham UTC+12:45) the minute component is preserved.
const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`);
const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz);
// Local 00:00 at this date = UTC midnight - offset.
return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000);
}
/**
* v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a
* calendar-day key in the user's IANA timezone. The window honours
* DST so the drill-down branch returns the correct 23 / 24 / 25-hour
* span for transition days.
*
* Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING
* calendar day — `< dayEnd` is the canonical half-open bound used by
* the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate.
*/
export function localDayWindow(
dateKey: string,
tz: string,
): { dayStart: Date; dayEnd: Date } {
const dayStart = localStartOfDay(dateKey, tz);
// Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon
// sidesteps every DST edge case for the calendar increment). Then
// re-extract the ISO date slice — guaranteed to be the next-day key.
const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`);
nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1);
const nextDateKey = nextUtcNoon.toISOString().slice(0, 10);
const dayEnd = localStartOfDay(nextDateKey, tz);
return { dayStart, dayEnd };
}
/**
* Group an array of per-sample rows into per-day buckets keyed by the
* user's timezone. Exposed for unit testing the bucketing semantics
* without booting Prisma.
*/
export interface PerSampleRow {
id: string;
type: MeasurementType;
value: number;
measuredAt: Date;
externalId: string | null;
/**
* Optional — only the mean-consolidation pass selects `unit` so it can
* read the canonical unit straight off the day's rows rather than
* issuing a separate query. The cumulative drain leaves it unselected.
*/
unit?: string;
}
export interface BucketedRows {
/** Map keyed by `YYYY-MM-DD` (user TZ). */
byDay: Map<string, PerSampleRow[]>;