import type { ObjectLiteral, Repository } from 'typeorm' /* * **저장된 값을 시각으로 읽는 두 가지 물음** (2026-08-28 실측). * * ── 무엇이 어긋났나 ───────────────────────────────────────────────────────── * `getRawOne`·`getRawMany` 는 드라이버가 준 값을 그대로 준다. sqlite 가 주는 그 값에는 **시간대가 * 없다**(`2026-08-27 14:37:20.049`). 그것을 `new Date()` 로 읽으면 서버의 지역 시간으로 해석해 * 한국에서 9시간이 밀린다. 저장된 값은 UTC 이므로 그 해석이 틀렸다. * * 네 자리에서 났다. 둘은 **지우는 경계**(경계가 이르게 밀리면 정리가 0건이 되어 멈춘다), 하나는 * 이어 읽기 지점(9시간을 건너뛰어 본 줄이 또 나왔다), 하나는 「이 목록은 언제부터 말할 수 있나」. * * ── 처음에 편법을 썼다 ────────────────────────────────────────────────────── * 「시간대가 없으면 UTC 로 읽는다」를 직접 적었다. 그것은 **드라이버가 어떤 문자열로 저장하는지를 * 응용 코드가 아는 것**이고, 이 저장소의 절대 규칙(다섯 드라이버·TypeORM 관용구만)에 어긋난다. * * 표준 문이 이미 있다. `Driver.prepareHydratedValue(value, column)` — 엔티티 변환 * (`RawSqlResultsToEntityTransformer`)이 지나는 바로 그 함수다. sqlite 구현이 공백을 `T` 로 바꾸고 * `Z` 를 붙이고, Postgres 구현은 이미 날짜 객체인 값을 그대로 낸다. 드라이버마다 자기 구현을 갖고 * 있으므로 이식성이 **정의상** 보장된다. * * ── 두 물음을 섞지 않는다 ─────────────────────────────────────────────────── * `hydratedDate` **테이블의 컬럼** 값을 읽는다 — 드라이버에게 물어본다 * `absoluteTime` **계약이 절대시각이라고 약속한** 값을 읽는다(유입 봉투 · 우리가 만든 커서) * * 앞의 것에 뒤의 규칙을 쓰면 편법이 되고, 뒤의 것에 앞의 것을 쓸 수는 없다(컬럼이 아니다). */ /** 시간대가 적혀 있나 — `Z`·`+09:00`·`-0500`. */ const HAS_ZONE = /([Zz]|[+-]\d{2}:?\d{2})$/ /** * 원시 결과에서 꺼낸 **컬럼 값** → 날짜. 읽을 수 없으면 `null`. * * 해석은 드라이버가 한다(`prepareHydratedValue`) — 엔티티로 받았을 때와 **같은 값**이 나온다. * 그래서 속도 때문에 원시로 받는 자리와 엔티티로 받는 자리가 서로 다른 답을 낼 수 없다. * * 원시로 받는 이유는 그대로 유효하다: 경계 한 줄을 찾을 때 색인만 읽으면 테이블을 열지 않는다. * 잃는 것은 컬럼 변환뿐이었고, 이 함수가 그것만 되돌려 준다. */ export function hydratedDate(repository: Repository, property: keyof T & string, value: unknown): Date | null { if (value === null || value === undefined) return null const column = repository.metadata.findColumnWithPropertyName(property) if (!column) { /* 컬럼을 못 찾으면 짐작하지 않는다 — 이름이 바뀌었다는 뜻이고, 그때는 값을 지어내면 안 된다. */ throw new Error(`[read-time] "${repository.metadata.name}" has no column for property "${property}"`) } const at = repository.manager.connection.driver.prepareHydratedValue(value, column) return at instanceof Date && !Number.isNaN(at.getTime()) ? at : null } /** * **계약이 절대시각이라고 약속한** 값 → 날짜. 읽을 수 없으면 `null`. * * 쓰는 자리는 둘이다. 유입 봉투의 `eventTime`(정규 계약이 절대시각을 요구한다)과 우리가 만든 * 커서(우리가 적은 것을 우리가 읽는다). 둘 다 테이블의 컬럼이 아니므로 드라이버에게 물을 수 없다. * * 시간대가 적혀 있지 않으면 UTC 로 읽는다 — 계약이 절대시각을 요구하므로 지역 시간으로 읽을 근거가 * 없다. 「지금」으로 메우지 않는다: 옛 사건이 최근으로 보이고, 경계를 정하는 자리에서는 지우면 안 * 되는 것을 지운다. */ export function absoluteTime(value: unknown): Date | null { if (value === null || value === undefined) return null if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value if (typeof value === 'number') { const at = new Date(value) return Number.isNaN(at.getTime()) ? null : at } if (typeof value !== 'string') return null const s = value.trim() if (!s) return null /* 시간대가 적혀 있으면 그 말을 믿는다 — 우리가 고쳐 붙이지 않는다. */ const at = new Date(HAS_ZONE.test(s) ? s : `${s.replace(' ', 'T')}Z`) return Number.isNaN(at.getTime()) ? null : at }